Skip to main content

max / alloy

Keep copied passwords out of the clipboard history Alloy shipped a password manager and a clipboard history, and the second recorded the output of the first. `gopass show -c` put a password on the clipboard, one of the two `wl-paste --watch cliphist store` watchers in the sway config wrote it into cliphist's database on disk, and $mod+Shift+v read it back afterwards. gopass clearing the clipboard when its timeout expires does not help: the copy has already been archived by then, so the clear removes the volatile copy and leaves the durable one. Nothing was misconfigured. Both halves worked exactly as designed and neither knew about the other, which is why nothing reported it. A user following docs/manual/10-the-shell.md on Alloy's own defaults wrote their passwords to disk in plaintext and was told nothing. GO alloy problem e7a9e38c. THE FIX IS A PRIVATE CLIPBOARD, in three small scripts. alloy-secret-copy drops a sentinel in $XDG_RUNTIME_DIR and then copies; alloy-clipstore, which the watchers now call instead of `cliphist store`, drops any selection while the sentinel is there; alloy-secret-clear clears the clipboard and removes it. gopass is routed through the first and last by GOPASS_CLIPBOARD_COPY_CMD and GOPASS_CLIPBOARD_CLEAR_CMD in environment.d -- variables gopass names in its own "no clipboard available" diagnostic, so this is a documented seam rather than a discovered one. WHY NOT THE MIME CONVENTION. The ecosystem has one: an extra offer type `x-kde-passwordManagerHint` with value `secret`, which KeePassXC emits and KDE's Klipper honours. Measured, it cannot work here: the cliphist in this image carries no such string, so nothing would honour it, and wl-copy offers one type at a time, so a password offered under the hint alone could not be pasted into anything asking for text/plain. Honouring the hint is still the better answer and belongs in a clipboard daemon Alloy owns, which would fix this for KeePassXC and Bitwarden users too. THE FAILURE DIRECTION IS THE DESIGN. A sentinel left behind pauses clipboard history, a feature quietly not working. A sentinel that never appears writes a password to disk. Those are not the same size of mistake, so the sentinel is written before the clipboard and a 90-second backstop removes it even if nothing calls the clear command. Rejected alternative: stopping the watcher for the duration, which fails the other way -- a crash between stop and restart kills clipboard history until the next login, silently. Asserted rather than trusted, because all three parts fail invisibly: the scripts exist and parse, both watchers go through alloy-clipstore and none calls cliphist directly, and environment.d names both gopass variables. The server branch proves all three scripts are absent. Verified on both built images: the assertion prints "private clipboard: 2 watchers gated, gopass routed through it", a selection with the sentinel present is dropped, and one without it reaches cliphist. What this does not fix, said plainly in STACK.md and in the manual: any program running as you can still read the clipboard while the password is on it. This closes the durable archive, not the live exposure.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 18:15 UTC
Signed with PGP, not checked
Commit: 61d5a5963cc72e74174ae4c15816f68ab015a2b3
Parent: e5cc3ea
8 files changed, +199 insertions, -5 deletions
M Containerfile +53 -1
@@ -1946,9 +1946,61 @@
1946 1946 done; \
1947 1947 echo "alloy-shot: $(echo "$modes" | wc -l) bound modes, all handled"; \
1948 1948 else \
1949 - rm -f /usr/bin/alloy-shot /usr/bin/alloy-menu /usr/bin/alloy-clipmenu; \
1949 + rm -f /usr/bin/alloy-shot /usr/bin/alloy-menu /usr/bin/alloy-clipmenu \
1950 + /usr/bin/alloy-clipstore /usr/bin/alloy-secret-copy /usr/bin/alloy-secret-clear; \
1950 1951 test ! -e /usr/bin/alloy-shot \
1951 1952 || { echo "profile=server still carries alloy-shot" >&2; exit 1; }; \
1953 + test ! -e /usr/bin/alloy-clipstore \
1954 + || { echo "profile=server still carries alloy-clipstore; there is no clipboard on a headless box" >&2; exit 1; }; \
1955 + fi
1956 +
1957 + # =====================================================================
1958 + # The private clipboard — assert the leak stays closed.
1959 + #
1960 + # Alloy ships a password manager and a clipboard history, and until
1961 + # 2026-08-17 the second recorded the output of the first: `gopass show -c`
1962 + # put a password on the clipboard and the sway config's watchers wrote it
1963 + # into cliphist's database, where $mod+Shift+v read it back. GO alloy
1964 + # problem e7a9e38c.
1965 + #
1966 + # Three parts have to agree or the leak is silently back, and no part
1967 + # reports its own absence:
1968 + #
1969 + # 1. the scripts exist and parse
1970 + # 2. the sway config's watchers call alloy-clipstore, not `cliphist store`
1971 + # 3. gopass is pointed at alloy-secret-copy through environment.d
1972 + #
1973 + # Miss (2) and the watcher stores everything. Miss (3) and gopass finds
1974 + # wl-copy by itself. Both look exactly like a working system: the password
1975 + # is on the clipboard, the paste works, and the only difference is a row in
1976 + # a database nobody opens. That is the same shape as the emoji alias and the
1977 + # swayosd unit path, which is why it is asserted rather than trusted.
1978 + RUN set -eu; \
1979 + if [ "$PROFILE" = client ]; then \
1980 + set -x; \
1981 + for script in alloy-clipstore alloy-secret-copy alloy-secret-clear; do \
1982 + test -x "/usr/bin/$script" \
1983 + || { echo "$script is missing or not executable; every copied password would be archived" >&2; exit 1; }; \
1984 + sh -n "/usr/bin/$script" \
1985 + || { echo "$script does not parse; the private clipboard would fail open" >&2; exit 1; }; \
1986 + done; \
1987 + watchers=$(grep -c '^exec wl-paste .*--watch alloy-clipstore' /etc/skel/.config/sway/config); \
1988 + [ "$watchers" = 2 ] \
1989 + || { echo "expected 2 clipboard watchers through alloy-clipstore, found $watchers; a password would reach cliphist" >&2; exit 1; }; \
1990 + ! grep -q '^exec wl-paste .*--watch cliphist store' /etc/skel/.config/sway/config \
1991 + || { echo "a watcher still calls cliphist store directly, so it records secrets too" >&2; exit 1; }; \
1992 + env=/etc/skel/.config/environment.d/alloy.conf; \
1993 + grep -q '^GOPASS_CLIPBOARD_COPY_CMD=/usr/bin/alloy-secret-copy$' "$env" \
1994 + || { echo "gopass is not pointed at alloy-secret-copy; it would call wl-copy itself and the leak is back" >&2; exit 1; }; \
1995 + grep -q '^GOPASS_CLIPBOARD_CLEAR_CMD=/usr/bin/alloy-secret-clear$' "$env" \
1996 + || { echo "gopass has no clear command, so clipboard history would stay paused until the backstop" >&2; exit 1; }; \
1997 + echo "private clipboard: 2 watchers gated, gopass routed through it"; \
1998 + else \
1999 + for script in alloy-clipstore alloy-secret-copy alloy-secret-clear; do \
2000 + test ! -e "/usr/bin/$script" \
2001 + || { echo "profile=server still carries $script; there is no clipboard and no gopass on this profile" >&2; exit 1; }; \
2002 + done; \
2003 + echo "private clipboard: nothing to gate on a headless profile"; \
1952 2004 fi
1953 2005
1954 2006 # =====================================================================
M docs/STACK.md +12 -2
@@ -414,11 +414,21 @@
414 414 **Sway integration:** two watchers `exec`'d from the sway config, and a picker bound to `Mod+Shift+V`:
415 415
416 416 ```
417 - exec wl-paste --type text --watch cliphist store
418 - exec wl-paste --type image --watch cliphist store
417 + exec wl-paste --type text --watch alloy-clipstore
418 + exec wl-paste --type image --watch alloy-clipstore
419 419 bindsym $mod+Shift+v exec $term --app-id=alloy-menu -e /usr/bin/alloy-clipmenu
420 420 ```
421 421
422 + **The watchers call `alloy-clipstore` rather than `cliphist store`, and that is a fix rather than a wrapper for its own sake.** Alloy ships a password manager and a clipboard history, and until 2026-08-17 the second recorded the output of the first: `gopass show -c` put a password on the clipboard, a watcher wrote it into cliphist's database, and `Mod+Shift+V` read it back later. gopass clearing the clipboard after its timeout does not help, because the copy has already been archived by then. A user following this manual on these defaults wrote their passwords to disk in plaintext and was told nothing.
423 +
424 + **The private clipboard, in three small scripts.** `alloy-secret-copy` writes a sentinel into `$XDG_RUNTIME_DIR` and then copies; `alloy-clipstore` reads the sentinel and drops the selection instead of storing it; `alloy-secret-clear` clears the clipboard and removes the sentinel. gopass is pointed at the first and last through `GOPASS_CLIPBOARD_COPY_CMD` and `GOPASS_CLIPBOARD_CLEAR_CMD` in `etc/skel/.config/environment.d/alloy.conf`, which are variables gopass names in its own diagnostics, so this is a documented seam rather than a discovered one.
425 +
426 + **Why a sentinel and not the MIME convention.** The ecosystem already has one: an extra offer type `x-kde-passwordManagerHint` with value `secret`, which KeePassXC emits and KDE's Klipper honours. Two things stop it working here. The cliphist in the image carries no such string, so nothing would honour it, and `wl-copy` offers one type at a time, so a password offered under the hint alone could not be pasted into anything asking for `text/plain`. Honouring the hint is still the better long-term answer and belongs in a clipboard daemon Alloy owns, which would fix this for KeePassXC and Bitwarden users too.
427 +
428 + **The failure direction is the design.** A sentinel left behind pauses clipboard history, which is a feature quietly not working. A sentinel that never appears writes a password to disk. Those are not the same size of mistake, so the sentinel is created before the clipboard is written and a 90-second backstop removes it even if nothing calls the clear command. The alternative considered and rejected was stopping the watcher for the duration, which fails the other way: a crash between stop and restart kills clipboard history until the next login, silently.
429 +
430 + **What this does not fix:** any application that can read the clipboard can still read the password while it is there. This closes the durable archive on disk, not the live exposure, and those are different categories of problem.
431 +
422 432 The picker is `usr/bin/alloy-clipmenu`: `cliphist list` into fzf into `cliphist decode` into `wl-copy`, which is the pipeline this section already described. It went unwritten longer than it should have, so the watchers filled a history from the first login that nothing could read back. `cliphist decode` rather than the fzf line itself is what makes images and multi-line text survive the round trip.
423 433
424 434 No config file; cliphist's storage lives at `~/.local/share/cliphist/db` and needs no tuning.
@@ -78,6 +78,17 @@
78 78 you want one, and the age identity that decrypts it should never live in the
79 79 repository it decrypts or travel with it.
80 80
81 + `gopass show -c` copies a password to the clipboard, and Alloy keeps that copy
82 + out of the clipboard history that `Mod+Shift+V` reads. That is not automatic
83 + anywhere else and it was not automatic here until 2026-08-17: the history
84 + watchers recorded every password anyone copied, and the entry outlived the
85 + clipboard itself. It works now because gopass is pointed at
86 + `alloy-secret-copy`, which marks the selection private, and the watchers skip
87 + anything so marked. Nothing to configure, and the reason it is worth knowing is
88 + the limit: this keeps passwords out of the history *on disk*, and any program
89 + running as you can still read the clipboard while the password is on it. Paste
90 + it and move on rather than leaving it there.
91 +
81 92 That identity lives on the machine, under `~/.config/gopass/age/`. Run `gopass
82 93 config` to see the paths your install is actually using rather than trusting
83 94 this page. It matters because it is the one part of the store that is not in
@@ -9,3 +9,16 @@
9 9 # and this variable is inert without it. It stays as a toolkit hint like
10 10 # the two above, not as browser configuration.
11 11 MOZ_ENABLE_WAYLAND=1
12 +
13 + # gopass copies through Alloy's private clipboard path rather than calling
14 + # wl-copy itself, so a password never reaches the clipboard history that the
15 + # sway config's two watchers fill. Without these two lines gopass finds
16 + # wl-copy on its own and the leak is back (GO alloy problem e7a9e38c).
17 + #
18 + # gopass names both variables in its own "no clipboard available" message, so
19 + # this is the documented seam rather than a discovered one. Both are needed:
20 + # the copy half marks the selection private, the clear half unmarks it, and
21 + # setting only the first would pause clipboard history until the backstop
22 + # fired.
23 + GOPASS_CLIPBOARD_COPY_CMD=/usr/bin/alloy-secret-copy
24 + GOPASS_CLIPBOARD_CLEAR_CMD=/usr/bin/alloy-secret-clear
@@ -112,8 +112,14 @@
112 112 # the other half, the presets, is in etc/systemd/user-preset/50-alloy.preset.
113 113 exec mkdir -p ~/Pictures/Screenshots
114 114 # Clipboard history: two watchers per docs/STACK.md#clipboard-history.
115 - exec wl-paste --type text --watch cliphist store
116 - exec wl-paste --type image --watch cliphist store
115 + #
116 + # Through alloy-clipstore rather than `cliphist store` directly, since
117 + # 2026-08-17. Both are the same one line apart: store the selection, unless
118 + # alloy-secret-copy marked it as a password on its way to a paste. Before that
119 + # these two lines archived every password gopass copied, and $mod+Shift+v read
120 + # them back (GO alloy problem e7a9e38c).
121 + exec wl-paste --type text --watch alloy-clipstore
122 + exec wl-paste --type image --watch alloy-clipstore
117 123 # swayosd-server backs the volume/brightness/caps overlays, and this line is
118 124 # the only thing that starts it. Checked rather than assumed, because the note
119 125 # above retires the sibling `exec`: SwayOSD-0.3.2 ships three binaries and
@@ -1,0 +1,27 @@
1 + #!/bin/sh
2 + # alloy-clipstore — store a clipboard selection, unless it is a secret.
3 + #
4 + # What the sway config's two `wl-paste --watch` lines call instead of
5 + # `cliphist store` directly. The read half is still alloy-clipmenu and the
6 + # store is still cliphist's; this only decides whether an item reaches it.
7 + #
8 + # The rule is one line: if alloy-secret-copy left its sentinel, this selection
9 + # is a password on its way to a paste and is not history. See that script for
10 + # why a sentinel rather than the `x-kde-passwordManagerHint` MIME convention,
11 + # and for the failure direction it is built around. GO alloy problem e7a9e38c.
12 + #
13 + # Consuming stdin on the skip path is deliberate. `wl-paste --watch` writes the
14 + # selection to this process; exiting without reading it leaves the writer with
15 + # a closed pipe and a SIGPIPE on the next selection, which is a broken watcher
16 + # rather than a skipped item.
17 +
18 + set -eu
19 +
20 + sentinel="${XDG_RUNTIME_DIR:-/tmp}/alloy-secret-clipboard"
21 +
22 + if [ -e "$sentinel" ]; then
23 + cat >/dev/null
24 + exit 0
25 + fi
26 +
27 + exec cliphist store
@@ -1,0 +1,21 @@
1 + #!/bin/sh
2 + # alloy-secret-clear — clear the clipboard and re-enable history.
3 + #
4 + # The other half of alloy-secret-copy, and what gopass runs through
5 + # $GOPASS_CLIPBOARD_CLEAR_CMD when its timeout expires. Clearing the clipboard
6 + # is wl-copy's job; removing the sentinel is what lets alloy-clipstore start
7 + # recording again.
8 + #
9 + # Order matters and is the reverse of the copy path. The clipboard is cleared
10 + # first and the sentinel removed second, so there is no window in which history
11 + # is live and the secret is still on the clipboard.
12 + #
13 + # Safe to run when nothing is pending: both steps are idempotent, and gopass
14 + # calls this on paths where it is not certain a copy happened.
15 +
16 + set -eu
17 +
18 + sentinel="${XDG_RUNTIME_DIR:-/tmp}/alloy-secret-clipboard"
19 +
20 + wl-copy --clear || true
21 + rm -f "$sentinel"