Skip to content

fix: mask credentials on screen, not on paste - #39

Merged
tkc merged 2 commits into
mainfrom
mask-on-screen
Sep 25, 2026
Merged

tkc merged 2 commits into
mainfrom
mask-on-screen

Conversation

@tkc

@tkc tkc commented Sep 25, 2026

Copy link
Copy Markdown
Owner

The paste-time redaction in #34/#35 solved the wrong problem. Thank you for catching it — your suspicion was exactly right, and it reproduces:

export AWS_SECRET_ACCESS_KEY=wJalr...   →   export AWS_SECRET_ACCESS_KEY=[redacted]

The shell assigns the literal string, and the failure surfaces much later in an AWS call, far from its cause.

The requirement, corrected

paste-time redaction (wrong) masking while drawing (this PR)
Value reaching the pty [redacted] — broken real — works as typed
Screen shows the secret masked
⌘C copies the masked text copies the real value

What needed protecting was the credential being visible — a screenshot, a screen share, someone walking past, scrollback an hour later — not the credential reaching the program.

How

Redactor::spans returns byte ranges; masked_cells builds each visible row, maps those ranges to columns, and the draw loop substitutes one bullet per cell. The grid keeps the real text, so ⌘C, the program's own repaint, and the agent-state detection are all untouched.

export AWS_SECRET_ACCESS_KEY=••••••••••••••••••••••••••••••••••••••••

One bullet per cell keeps the column count identical, so a full-screen UI drawn on top does not shift.

The part that can silently go wrong

Byte offsets and columns diverge as soon as a double-width character appears. A test masks 鍵は AKIAIOSFODNN7EXAMPLE です and asserts the bullets land on the key, that 鍵は and です survive, and that there are exactly 20 of them. It caught a real bug in the test helper on the first run — the logic itself was right.

Cost

0.041 ms median (p90 0.043) for a full 47×163 screen with a credential on every eighth line, against roughly 1 ms to build a frame — about 4%. An #[ignore]d test re-measures it.

Also

  • [paste] → [screen]: the table now describes what is drawn. Never shipped in a release, so nothing to migrate.
  • ⌥⌘V and Action::PasteRaw are gone. With paste left alone there is nothing to escape from.
  • The README states plainly that masking is not confidentiality: the bytes are still in the grid, the scrollback and the history database. Someone with your machine can read them; someone looking at your screen cannot.
  • docs/references/paste.md records the correction and why the boundary moved, alongside the earlier iTerm2 research and Claude Code's redactForDisplay (which uses only high-confidence rules for exactly this reason — on a screen a human reads, a false positive is the bigger harm).

Test

237 pass, 2 ignored. clippy -D warnings and fmt --check clean.

🤖 Generated with Claude Code

tkc and others added 2 commits September 18, 2026 08:54
Claude Code resumes its task by itself when a usage limit resets, but
only while its session is alive, which makes leaving termit open the
thing that gets work done overnight. A sleeping Mac freezes every
process, so the reset passes and nothing happens -- and none of the
assertions already on this machine (coreaudiod, sharingd, powerd) hold
PreventSystemSleep, so the default state is not enough.

`caffeinate -is termit` covers it, and releases both assertions when
termit exits. Verified: PreventSystemSleep goes 0 -> 1 while it runs and
back to 0 after. The two limits are stated, because both are easy to
trip over: -s applies only on AC power, per its man page, and neither
flag stops a laptop sleeping when the lid closes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The paste-time redaction shipped in #34/#35 solved the wrong problem.
The requirement is that a credential still works and simply is not
visible, not that it never reaches the program. Redacting the paste
turned `export AWS_SECRET_ACCESS_KEY=wJalr...` into
`export AWS_SECRET_ACCESS_KEY=[redacted]`, so the shell assigned the
literal string and the failure surfaced much later, in an AWS call far
from its cause.

Masking now happens while drawing. The grid keeps the real text and the
pty gets the real bytes, so a pasted export works exactly as typed and
⌘C copies the real value; what disappears is the credential being
visible in a screenshot, a screen share, or scrollback an hour later.
Redactor::spans returns byte ranges, masked_cells maps them to columns,
and the draw loop substitutes a bullet per cell so column alignment --
and any full-screen UI drawn on top of it -- does not shift.

The byte-to-column mapping is the part that can silently go wrong, so a
test masks a line with double-width characters ahead of the secret and
asserts the bullets land on the key and nowhere else.

Costs 0.041ms median for a full 47x163 screen with a credential every
eighth line, against roughly 1ms to build a frame. An ignored test
re-measures it.

`[paste]` becomes `[screen]`, since the table now describes what is
drawn. ⌥⌘V and Action::PasteRaw are gone: with paste left alone there is
nothing to escape from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tkc
tkc merged commit ba31c65 into main Sep 25, 2026
1 check passed
@tkc
tkc deleted the mask-on-screen branch September 25, 2026 06:52
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