split machine-specific stores into mask hooks

This commit is contained in:
2026-08-13 02:08:18 +00:00
parent c6cb958ba5
commit 28d58728cf
3 changed files with 109 additions and 42 deletions
+66
View File
@@ -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())
+33 -37
View File
@@ -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 the writer never notices. A `sed -i` would swap the inode out and the running
session's appends would vanish into an unlinked file. session's appends would vanish into an unlinked file.
Covers both stores a human can actually read back: WHAT THIS OWNS: Claude Code's own stores, on any machine —
- Claude Code transcripts ~/.claude/projects/**/*.jsonl `~/.claude/projects/**/*.jsonl`. Nothing else is universal.
- Postgres chat_session_events (what the web chat replays)
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 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 `queue-operation` record, and a rolling `last-prompt` cache — plus once more for
@@ -27,10 +32,8 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
PROJECTS = Path.home() / ".claude" / "projects" PROJECTS = Path(os.environ.get("MASK_PROJECTS_DIR", Path.home() / ".claude" / "projects"))
PGDB = os.environ.get("MASK_PGDB", "officer_dev") HOOKS_DIR = Path(os.environ.get("MASK_HOOKS_DIR", Path(__file__).resolve().parent / "mask-hooks.d"))
PGHOST = os.environ.get("MASK_PGHOST", "127.0.0.1")
PGUSER = os.environ.get("MASK_PGUSER", "postgres")
def mask_file(path: Path, secret: bytes, mask: bytes) -> int: 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 return 0
def mask_postgres(secret: str, mask: str) -> int: def run_hooks(secret: str, mask: str) -> None:
"""Replace inside the jsonb payload. Returns rows updated, -1 if unavailable.""" """Every executable in HOOKS_DIR gets secret+mask on stdin."""
sql = ( if not HOOKS_DIR.is_dir():
"update chat_session_events set event = " return
"replace(event::text, %(s)s, %(m)s)::jsonb " hooks = sorted(h for h in HOOKS_DIR.iterdir() if h.is_file() and os.access(h, os.X_OK))
"where event::text like %(l)s;" if not hooks:
) return
# psql has no bind params, so quote by doubling any single quotes. The secret print(f"\nhooks ({HOOKS_DIR}):")
# is hex/base64 in practice, but never build SQL from unchecked input blindly. for hook in hooks:
q = lambda v: "'" + v.replace("'", "''") + "'" try:
stmt = sql.replace("%(s)s", q(secret)).replace("%(m)s", q(mask)).replace("%(l)s", q(f"%{secret}%")) out = subprocess.run(
env = {**os.environ, "PGPASSWORD": os.environ.get("MASK_PGPASSWORD", "postgres")} [str(hook)], input=f"{secret}\n{mask}\n",
try: capture_output=True, text=True, timeout=60,
out = subprocess.run( )
["psql", "-h", PGHOST, "-U", PGUSER, "-d", PGDB, "-tAc", stmt], line = (out.stdout.strip() or out.stderr.strip() or "(no output)").splitlines()[0]
capture_output=True, text=True, env=env, timeout=30, status = "" if out.returncode == 0 else f" [exit {out.returncode}]"
) print(f" {hook.name}: {line}{status}")
except (FileNotFoundError, subprocess.TimeoutExpired): except subprocess.TimeoutExpired:
return -1 print(f" {hook.name}: TIMED OUT")
if out.returncode != 0: except OSError as err:
print(f" ! postgres: {out.stderr.strip()[:120]}", file=sys.stderr) print(f" {hook.name}: failed to run ({err})")
return -1
return int(out.stdout.strip().split()[-1]) if out.stdout.strip() else 0
def main() -> int: def main() -> int:
@@ -81,21 +82,16 @@ def main() -> int:
print("refusing: secret under 12 chars would mask too much", file=sys.stderr) print("refusing: secret under 12 chars would mask too much", file=sys.stderr)
return 2 return 2
mask = "*" * len(secret) mask = "*" * len(secret)
sb, mb = secret.encode(), mask.encode()
total = 0 total = 0
for path in sorted(PROJECTS.rglob("*.jsonl")): for path in sorted(PROJECTS.rglob("*.jsonl")):
n = mask_file(path, sb, mb) n = mask_file(path, secret.encode(), mask.encode())
if n: if n:
total += n total += n
print(f" {n:>3} {path}") print(f" {n:>3} {path}")
print(f"transcripts: {total} occurrence(s) masked") print(f"transcripts: {total} occurrence(s) masked")
rows = mask_postgres(secret, mask) run_hooks(secret, mask)
if rows < 0:
print("postgres: skipped (psql unavailable or errored)")
else:
print(f"postgres: {rows} row(s) updated")
print("\nStill holding the real value, deliberately:") print("\nStill holding the real value, deliberately:")
print(" - the tool's own config (that is how it authenticates)") print(" - the tool's own config (that is how it authenticates)")
+10 -5
View File
@@ -89,11 +89,16 @@ chat histories, and that is the copy that matters to him.
printf '%s' '<secret>' | ~/projects/claude-mds/tools/mask-secret.py printf '%s' '<secret>' | ~/projects/claude-mds/tools/mask-secret.py
The script covers both readable stores — Claude Code's `~/.claude/projects/**` It masks Claude Code's own transcripts (`~/.claude/projects/**/*.jsonl`) with
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
asterisks of the same length, overwriting in place so the live session's are not lost. Read the header before changing it; the same-length, same-inode
appends are not lost. Read the header before changing it; the same-length, detail is load-bearing.
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 **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 a temporary one so you can drive an API — Nginx Proxy Manager, a router, a