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())
+30 -34
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
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")}
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(
["psql", "-h", PGHOST, "-U", PGUSER, "-d", PGDB, "-tAc", stmt],
capture_output=True, text=True, env=env, timeout=30,
[str(hook)], input=f"{secret}\n{mask}\n",
capture_output=True, text=True, timeout=60,
)
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
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)")
+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
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