Skip to content

Writing: scrub passwords, codes, cards and tokens out of Save my writing day files - #1905

Merged
r3dbars merged 2 commits into
mainfrom
claude/quirky-wing-c41afa
Sep 29, 2026
Merged

r3dbars merged 2 commits into
mainfrom
claude/quirky-wing-c41afa

Conversation

@r3dbars

@r3dbars r3dbars commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Why

Save my writing day files (<capture library>/writing/Writing_YYYY-MM-dd.md) were saving passwords verbatim wherever macOS secure input is off: sudo and ssh prompts in Terminal, iTerm2, Warp, Ghostty and VS Code, read -s, web fields with "show password" on, OTP and PIN boxes, card forms. The only guards were the secure-input flag and a bundle-ID list of password managers. Nothing ran SecretRules on day files, and the MCP/CLI tools hand those files to agents.

What changes

WritingSecretScrubber (new, Sources/TranscriptedWriting/Core/Text/). Pure, Foundation-only, local, deterministic. It works in three layers over one entry (one line per keyboard segment):

  1. Line rules, which judge a line by the lines around it:
    • Terminal password prompts. In terminals and editors with a terminal, a line that runs a prompting command (sudo, su, ssh, passwd, mysql -p, psql, read -s, ssh-keygen, gpg, docker login, git push, ansible --ask-become-pass, …) makes the next 1–3 single-token lines passwords. It stops at the first line that looks like a command. A passphrase prompt's first answer may contain spaces.
    • Standalone password-shaped lines (any app except editors): a single token with letters and digits mixed, or a symbol between letters. Paths, URLs, hosts and file names, versions, dates, ticket IDs, hashes, units and code expressions are excluded.
    • One-time codes: 6–8 digit lines, 482 913, digits split over boxes (one per line). A 4–5 digit line counts only after a line that mentions a code or PIN. Recovery-code lists under "backup codes" also count.
    • Cards split over boxes (Luhn plus an issuer prefix), and the expiry and CVV typed right after a card.
    • One-character-per-line runs. Some apps don't move the caret per key, and a terminal with echo off never does, so each keystroke becomes its own segment. A run of these lines is judged as the word it spells. Your real day files already show this: 105 one-character lines in Claude desktop entries.
    • Words with a number on the end (Summer2024) count as passwords in browsers (where show-password fields are) and terminals, but not versioned tools like python3. In chat, mail and notes they're usually names (Python3, macOS26), so there they count only after a password: line or with a common password stem like hunter or qwerty.
  2. SecretRules for the structured shapes (IBAN, SSN, AWS/OpenAI/GitHub keys, JWT, PEM). Emails and phone numbers are kept, because this is your own writing.
  3. Inline rules:
    • labelled values: password: …, "the wifi password is …", PIN 4821, verification code 482913, cvc 123, exp 04/28
    • env-style SOME_TOKEN=… and config keys like github_token: …
    • --password / --token flags, mysql -pX, sshpass -p, redis-cli -a, curl -u user:pass, Bearer …
    • credentials in a URL (user:pass@)
    • more token formats: Slack, Stripe, Google, GitHub fine-grained, GitLab, npm, Hugging Face, SendGrid, Telegram, Twilio, Slack/Discord webhooks
    • a strict generic high-entropy token rule

Each redaction becomes ⟨redacted:<kind>⟩, the same token shape SecretRules uses.

Where it runs:

  • WritingEntryComposer.closeOpen() scrubs every entry before the recorder sees it. The composer keeps each app's last 6 raw lines in memory only as context. Return breaks the segment in a terminal, so sudo apt update closes as its own entry and the password starts the next one. Context is per app and history (a Slack reply in between doesn't lose it) and lasts 5 minutes, as long as sudo waits for a password.
  • Box fragments (one character, or up to 5 characters with no letters) no longer count toward the composer's 3-word scrap limit. Without that, a card typed over four boxes closed after the third box and the last group plus the expiry landed in the next entry.
  • An entry that was nothing but a secret isn't saved. Words:, Characters: and the heading title come from the scrubbed text, since the title is the first 7 words and could otherwise carry the password.
  • Files already on disk: WritingDayFileRescrubber runs once per WritingSecretScrubber.rulesVersion when Writing starts (WritingDayFileWriter.start(), which never runs in automated launches).
    • It takes the recorder's serial queue one file at a time, so keyboard batches wait at most one file. It stops between files when Writing stops.
    • It rewrites a file only if it still holds the exact bytes that were read (compare-and-swap), so an append from another recorder or a delete-all is never overwritten or undone.
    • It only touches Writing_*.md directly inside a folder named writing, and it writes atomically with owner-only permissions through the same path appends use.
    • Changed sections get their title and counts redone, sections that were only a secret are removed, and files with nothing to scrub are left byte for byte.
    • Each rewritten file posts the usual save notification, so Home and Today refresh.
    • If any file fails, it retries next launch. It logs counts only to the local writing diagnostics log.

SecretRules gains scrubGenericTokens and scrubCardNumbers in ScrubConfig. Both default to on, so prompt context and Screen Memory behave exactly as before. The day-file scrubber turns them off and runs stricter versions. The loose generic rule takes long paths (Sources/TranscriptedCore/AudioTests/…) and SCREAMING_SNAKE names, and the loose card scan takes UUIDs and entry IDs whose digit groups happen to pass Luhn. The measurement below shows how much that matters for day files. The deviation is recorded in docs/writing-port-ledger.md.

Why not a model

A small local classifier would need labelled typed-password data we don't have, and it would be much harder to measure or reason about than rules. Most of the signal is context a character model can't see anyway: which app this is, the line before, whether a sudo just ran. What rules can't catch is letters-only passwords with no context, and a classifier would struggle to tell sunshine from a word too. If we want one later, it fits as one more line rule behind the same API.

Measured false positives and false negatives

bash scripts/dev/measure-writing-scrubber.sh: a labelled corpus (Tests/Fixtures/writing-secret-corpus.json, 70 secret cases and 28 ordinary texts), plus a sweep over text that contains no secrets: this repo's docs, code, scripts and 3,000 commit messages, cut into entry-sized chunks. The last column is the option this replaces, running SecretRules as the prompt path does.

Category Secret cases Caught Missed Recall SecretRules alone
terminal-prompt 18 17 1 94.4% 0.0%
standalone 12 8 4 66.7% 0.0%
code 11 10 1 90.9% 0.0%
card 7 7 0 100.0% 57.1%
labelled 20 20 0 100.0% 5.0%
token 11 11 0 100.0% 81.8%
all 79 73 6 92.4% 17.7%

Missed:

  • terminal-prompt: brew asks for sudo with no sudo typed (known miss)
  • standalone: letters-only password (known miss)
  • standalone: long letters-only passphrase (known miss)
  • standalone: passphrase with spaces in a web field (known miss)
  • standalone: short password (known miss)
  • code: 4-digit PIN in a web field (known miss)

Ordinary texts: 34, changed: 2 (5.9%)

  • chat: known FP: arch name (known)
  • terminal: known FP: alias after git push (known)

Repo sweep (text that has no secrets: every redaction is a false positive unless noted)

Corpus Files Entries Lines Entries changed Redactions Kinds SecretRules alone: entries changed
Docs (Markdown) as Notes 133 4922 14119 2 (0.0%) 2 api-key 1, password 1 368 (7.5%)
Swift sources as VS Code 702 46732 189593 1 (0.0%) 1 api-key 1 201 (0.4%)
Shell scripts as Terminal 62 2963 11890 0 (0.0%) 0 266 (9.0%)
Commit messages as Slack 1 7990 16184 0 (0.0%) 0 1461 (18.3%)
  • Known misses (6 in the corpus), on purpose:

    • letters-only passwords with no context (sunshine in a web field)
    • letters-only or spaced passphrases in a web field
    • passwords under 6 characters
    • a bare 4-digit PIN in a web field
    • a password for a sudo that brew asked for with no sudo typed

    Also missed: Tigers2024 alone in chat or notes with no label before it, a card whose boxes are more than a minute apart (the composer splits the entry), and alphanumeric codes over boxes (B7X then 9QK). Catching these would mean redacting ordinary one-word replies, years and names.

  • Known false positives, on purpose: a standalone x86_64, Room4B-style tokens with letters and digits woven together, and a 4+ letter alias typed right after git push. The sweep's 3 hits are the Sparkle public key (base64, twice; indistinguishable from a secret) and FEE005+MEE007+MEE008. The Visa test card in SecretRules' doc comment is excluded from the sweep along with the two rule files, which are catalogues of example secrets.

  • Your real day files (counts only, no text read): 2 files, 107 sections, 0 redactions, 0 removals. So nothing in your current writing would change, and nothing secret seems to have been typed yet (no terminal entries).

Tests

  • WritingSecretScrubberTests: promises 1–7, table-driven. It was written by a separate agent from a one-page spec, without reading the implementation.

  • WritingEntryComposerTests (13 added): cross-entry sudo context, context expiry, context surviving another app, only-redaction entries dropped, counts. Also, through the real composer: a card typed over four boxes with expiry and CVC, a 6-box OTP, and a sudo password arriving one character per segment.

  • WritingDayFileRescrubberTests: untouched clean files, byte-exact neighbours, section removal, per-app context, idempotence, and only Writing_*.md rewritten with owner-only permissions.

  • WritingSecretCorpusTests: every labelled secret not marked a known miss is gone, and every ordinary text not marked a known false positive comes back byte for byte.

  • WritingSecretScrubberRegressionTests: the independent review's inputs, meaning ordinary sentences the first version redacted and labelled secrets it let through.

  • Proved they can fail: eight deliberate breaks, each of which went red:

    • drop the terminal-only check
    • ignore the context
    • keep only-redaction entries
    • drop Luhn on split cards
    • count box fragments as words
    • use a single context slot
    • use a 2-minute context window
    • treat a word with a number as a password everywhere

    Two more mutations stayed green because they hit dead code, which I deleted.

Checks

bash check.sh ran on the branch before merging current main. Every step passed except run-tests.sh: 19,777 of 19,790 passed, and the only failures are in MeetingPromptDetectorTests, which also fails on a clean origin/main on this Mac, with 2–13 failures depending on the run. The steps:

  • build.sh
  • build-deps --force
  • run-integration-smoke
  • swift test
  • test-shape, concurrency census, source pins, doc paths

After merging current main (the only conflict was a Markdown one in the port ledger, keeping both bullets), I re-ran:

  • build-deps --force and build.sh --no-open: pass
  • the whole Writing target under swift test: 880 tests pass
  • run-tests.sh: 19,924 of 19,932 pass, and the 8 failures are the same flaky MeetingPromptDetectorTests
  • the nightly security check's secret scan: 25/25
  • known-traps, test-shape, source pins, doc paths: pass

Token-shaped test strings are \u-escaped in the fixture or concatenated in Swift, so the nightly scanner and GitHub secret scanning stay quiet.

Not covered here

  • Personal History and the predictor. The same keystrokes also reach the encrypted Personal History log and the next-word predictor unscrubbed, so a terminal password could come back as a ghost suggestion. That's a separate change (task flagged).
  • Real-keyboard proof. No live proof yet that a real Terminal sudo prompt produces the segment shape these tests assume. That needs typing a throwaway password at sudo -k; sudo -v with Save my writing on, then checking the day file.

Review

An independent review ran against the full diff and came back SHIP WITH FIXES. Its main catch was real: split boxes still reached disk through the composer, because my first tests fed the scrubber pre-joined strings the composer never produces. It also found false positives the one-time rescrub would have made permanent, a single-slot terminal context, the rescrub holding the recorder queue for a whole folder with a race after teardown, and a set of labelled-value leaks. All of those are fixed here with tests. Not fixed:

  • feat: Add Gemini-inspired aurora recording indicator #6: rescrubbing again after the three passwd answers were dropped can take one more line. It errs toward redacting and only matters at a rules bump.
  • Two documented gaps: the rescrub only runs when Writing starts, and the version key is per Mac.
  • Sections an older build already split across entries (4\n8\n2 then 9\n1\n3) aren't rejoined by the rescrub. That only matters for files written before this change.
  • Performance: a huge day file (860 sections) takes about 4.6 s to rescrub in a release build. The queue is held for one file at a time, so that's the longest a keyboard batch or quit waits, once per rules bump.

A second review round checked the fixes. It confirmed the composer fix through the real composer with more box layouts (Amex 4-6-5, split sudo, text around the boxes). It also caught one regression I'd introduced: PIN 4821 followed by a new line or more words leaked again, because the "must end the clause" lookahead only matched at the end of the whole text. That's fixed (6–8 digits always count; 4–5 digits are skipped only before a count noun like "lines"). Also fixed in that round: word+number passwords in terminals and after a password: line, multi-digit OTP boxes, month and year boxes after a card, the First pass: v2 / basic Real-Time / $db_pass false positives, and the ledger notes. Final verdict after round two: SHIP WITH FIXES, with those fixes applied here.

🤖 Generated with Claude Code

…ing day files

Secure input and the password-manager list miss a lot of password typing:
sudo/ssh prompts in terminals, read -s, show-password web fields, OTP and
PIN boxes, card forms. Every entry now goes through WritingSecretScrubber
before it reaches a day file, with each app's previous entry tail as
context (a terminal password usually starts the entry after its sudo
line). Box fragments no longer count toward the scrap word limit, so a
card or code typed over several boxes stays one entry. An entry that was
only a secret isn't saved. Day files already on disk are rescrubbed once
per rules version when Writing starts, one file at a time on the
recorder's queue, and only if the file hasn't changed since it was read.

SecretRules gains two opt-out flags (generic tokens, loose card scan),
default on; the day-file scrubber runs stricter versions because the loose
ones took paths, SCREAMING_SNAKE names and UUIDs.

Measured with scripts/dev/measure-writing-scrubber.sh: 73/79 labelled
secrets caught (the 6 misses are listed), 3 changed entries in ~62k
entries of repo docs, code, scripts and commit messages, against 17.7%
recall and 2,296 changed entries for SecretRules alone.
…1afa

# Conflicts:
#	docs/writing-port-ledger.md
@r3dbars
r3dbars merged commit 2d0a9b9 into main Sep 29, 2026
8 checks passed
@r3dbars
r3dbars deleted the claude/quirky-wing-c41afa branch September 29, 2026 01:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant