add mask-secret tool and rule
This commit is contained in:
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mask a secret everywhere the chat history keeps it.
|
||||
|
||||
Read the secret on stdin, never as an argument — argv is visible in `ps` to any
|
||||
local user, and it lands in the transcript a second time.
|
||||
|
||||
printf '%s' "$SECRET" | tools/mask-secret.py
|
||||
|
||||
Replaces the secret with asterisks of the SAME LENGTH. That matters: the live
|
||||
session is appending to its transcript while this runs, so the file is opened
|
||||
'r+b' and overwritten in place. No rename, no truncate, the inode survives and
|
||||
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)
|
||||
|
||||
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
|
||||
every command that carries it. Do not assume one hit.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
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")
|
||||
|
||||
|
||||
def mask_file(path: Path, secret: bytes, mask: bytes) -> int:
|
||||
"""Overwrite in place, preserving the inode. Returns occurrences masked."""
|
||||
try:
|
||||
with open(path, "r+b") as f:
|
||||
data = f.read()
|
||||
hits = [m.start() for m in re.finditer(re.escape(secret), data)]
|
||||
for off in hits:
|
||||
f.seek(off)
|
||||
f.write(mask)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
return len(hits)
|
||||
except (OSError, PermissionError) as err:
|
||||
print(f" ! {path}: {err}", file=sys.stderr)
|
||||
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 main() -> int:
|
||||
secret = sys.stdin.read().strip()
|
||||
if len(secret) < 12:
|
||||
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)
|
||||
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")
|
||||
|
||||
print("\nStill holding the real value, deliberately:")
|
||||
print(" - the tool's own config (that is how it authenticates)")
|
||||
print(" - this session's live context, re-sent to the API each turn")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -81,6 +81,31 @@ Three things still hold, because they are cheap and they outlive the session:
|
||||
warning, as a fact he may want later. Tools do this quietly — `tea` stores its
|
||||
token in plaintext at `~/.config/tea/config.yml`, and nothing announced it.
|
||||
|
||||
### Mask it in the chat history once you are done
|
||||
|
||||
When he pastes a secret, mask it in the stored history as soon as you have
|
||||
finished using it — same turn, unprompted. He reloads and sometimes shares whole
|
||||
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.
|
||||
|
||||
Two habits that matter more than the cleanup:
|
||||
|
||||
- **Keep secrets off command lines.** Pass them by stdin or an env var. Argv is
|
||||
visible in `ps`, and every command you run is stored verbatim. Of seven copies
|
||||
of one pasted token, four were commands — mine, not his.
|
||||
- **Expect several copies per paste.** The harness writes the `user` message, a
|
||||
`queue-operation` record and a rolling `last-prompt` cache. Never assume one.
|
||||
|
||||
The tool's own config keeps the real value. That is not a leak, it is how the
|
||||
tool authenticates.
|
||||
|
||||
Never revoke, rotate or invalidate a credential on your own. That breaks things
|
||||
he did not ask you to break.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user