split machine-specific stores into mask hooks
This commit is contained in:
Executable
+66
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user