Skip to content

fix(redaction): stop reading shell expansions as secret assignments - #13

Merged
rt2zz merged 4 commits into
mainfrom
fix/redaction-shell-pwd
Oct 21, 2026
Merged

rt2zz merged 4 commits into
mainfrom
fix/redaction-shell-pwd

Conversation

@rt2zz

@rt2zz rt2zz commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

What happened

A dotfiles sidecar (redaction = "secrets", review = "required") published this zsh line from home/.zshrc_mods:

[[ -n "$sub" && "${PWD:t}" != "$sub" ]] && where+=" %F{cyan}${${PWD:t}//\%/%%}%f"

as:

[[ -n "$sub" && "${PWD:<SECRET>" != "$sub" ]] && where+=" %F{cyan}${${PWD:<SECRET>"

The other machines received the redacted blob as their working copy, so zsh -n failed there and every interactive shell broke. The authoring machine keeps its original, so the author never saw it. The review gate approved the change: the reviewer saw only placeholders, and the instructions called them "not leaks".

Root cause

PWD is a password key (pwd in COMPACT_SENSITIVE_KEYS, PWD in the last-part list). BARE_ASSIGNMENT_SECRET_REGEX read zsh's ${PWD:t} as the assignment PWD: t} and replaced the "value", including the closing brace. The same shape broke ${PASSWORD:0:3}, ${GITHUB_TOKEN:?msg}, $TOKEN:foo, and, on the value side, export GITHUB_TOKEN=$(gh auth token) (which became <TOKEN> auth token)).

Changes

  • Shell expansions are reads, not assignments. A key reached through ${… (including zsh (flags) and the #!^=~ prefixes), or through $KEY before a :, is skipped by the assignment patterns. $password = '…' (PHP/PowerShell) is still redacted.
  • Literal defaults are still redacted, with the braces kept. Under a sensitive name, the word of :-, :=, :+ (and the forms without :) is replaced up to its matching brace. The brace match handles escapes, quotes, backticks, and nested ${}/$(). So ${DB_PASSWORD:-hunter2} → ${DB_PASSWORD:-<SECRET>}, where main produced a broken ${DB_PASSWORD:<SECRET>. A word that is only a reference passes through. Nesting is bounded, so hostile input stays linear.
  • References aren't secrets. An unquoted $UPPER_NAME, a ${NAME}, or a command name ($(gh …, exactly the span the old pattern would have replaced) is left alone. A quoted "$(…)", $((…)), $(cat<<<x), and anything less plain are redacted as before.
  • PWD stays a password key outside expansions (ODBC UID=sa;PWD=…;). Inside an expansion, PWD/OLDPWD are the shell's working directory.
  • REDACTION_FILTER_REVISION 2 → 3, so every checkout renormalizes on its next snapshot. The authoring machine then republishes files the old filter mangled.
  • Review packet: the instructions now list every added line that carries a placeholder (file:line in the new file, published text only, never originals). They tell the reviewer that a false positive replaces the text on every other machine, and to reject placeholders that replaced non-credentials or leave a file broken. "Ignore correctness" now applies only to what the author wrote.
  • Docs: docs/redaction.md gains "Shell expansions" and "False positives break other machines, not yours" (with this incident). docs/review.md describes the inventory.

Considered and not done

  • Syntax checks (zsh -n/bash -n) before and after redaction. True positives break shell syntax too (export GITHUB_TOKEN=<TOKEN> parses as a redirection), so a check can't tell false positives from real ones. It would also spawn interpreters inside the clean filter on every git add.
  • Rewriting the author's working copy to show the placeholder. That destroys real secrets.
  • Redaction counts in sidecar status. Deferred: status does no redaction work today, the snapshot message and sidecar redactions already exist, and the gated path now gets the inventory.
  • URL_CREDENTIALS_REGEX still rewrites ${a}//b:c@ and ${v//a:b@/x}. Tightening it would drop schemeless detection; these are rare.

Rollout notes

Every machine needs the new version: an old filter still applies the old patterns when it re-commits a file. After upgrade, the authoring machine's next snapshot republishes the original (through review on gated peers), and clean checkouts elsewhere fast-forward to it.

Tests

bun run check passes. bun run test: 285 passed, 23 skipped (integration tests not run). New cases:

  • the exact incident line through redactText and redactBuffer (the clean filter's path), published unchanged in both modes
  • sibling false positives
  • leak regressions with idempotency checks
  • a linear-time nesting test
  • packet inventory and diff-parser tests (hunk counts, quoted/tab-terminated paths)

Designed and reviewed with Codex (gpt-6.1-sol) over four rounds.

🤖 Generated with Claude Code

rt2zz and others added 4 commits October 21, 2026 10:01
`${PWD:t}` matched the bare-assignment pattern as `PWD: t}` (PWD is a
password key), and redacting the "value" ate the closing brace. The
published dotfile no longer parsed, and every other machine received it as
its working copy while the author's machine kept the original.

- A key read through `${…}` or `$KEY:` is never an assignment. The one
  part of an expansion that can hold a literal secret, the word of a
  default, assign, or alternate, is redacted inside the braces instead.
- Values that only refer to something else (`$(gh auth token)`, `$GH_TOKEN`,
  `${GH_TOKEN}`) are no longer replaced.
- PWD stays a password key outside expansions (ODBC `PWD=`).
- Filter revision 3, so every checkout reprocesses tracked files and the
  authoring machine republishes what the old filter mangled.
- Review packets list every added line carrying a placeholder and tell the
  reviewer that a false positive replaces the text on every other machine.

Co-Authored-By: Claude <noreply@anthropic.com>
- `${#NAME:-word}` defaults are redacted: the read exemption and the default
  rule share one grammar.
- Double-quoted strings skip their own expansions, so a quote inside
  `$(…)` no longer ends the word early and leaves a secret behind.
- Only an unquoted command with nothing inside to redact is left alone;
  quoted commands and `$((…))` arithmetic are redacted as before.
- Nesting is bounded and reference words are scanned once, so hostile input
  stays linear in the clean filter.
- The review packet decodes git-quoted and tab-terminated paths.

Co-Authored-By: Claude <noreply@anthropic.com>
A command substitution was left alone when redacting its inside changed
nothing, but `$(cat<<<hunter2)` has no whitespace, so the old pattern
redacted all of it. Recursing into the command was also combinatorial.
Now only the span the old pattern would have replaced is exempt, and only
when it is `$(` and a plain command name.

Co-Authored-By: Claude <noreply@anthropic.com>
`PASSWORD=$(sk-…)` passed the command-name exemption, then the token
patterns replaced the name, and the next pass no longer exempted
`$(<API_KEY>)`. A placeholder now passes as a command name. The docs no
longer claim a command's arguments go unchecked.

Co-Authored-By: Claude <noreply@anthropic.com>
@rt2zz
rt2zz merged commit ad2f246 into main Oct 21, 2026
1 check passed
@rt2zz
rt2zz deleted the fix/redaction-shell-pwd branch October 21, 2026 19:21
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