This post builds a production-quality clipboard watcher in Go: regex-based secret detection, thread-safe polling with context cancellation, desktop notifications on redaction, and OS-level background service setup for macOS and Linux.
The Approach: Polling with Pattern Matching#
The original version of this program used a fixed string prefix ("password: ") and a 1-second fixed sleep. Both choices are fragile: the prefix misses everything that does not use that exact format, and the fixed sleep creates a race condition where the clipboard can be overwritten between the detection interval.
The improved approach:
- Poll on a configurable interval (500ms is a reasonable default)
- Detect secrets using a set of compiled regexes covering common secret formats
- Redact by replacing most characters with asterisks, keeping the last N chars for verification
- Notify the user via a desktop notification so the redaction is not silent
- Run as a per-user background service, not as root
Secret Detection Patterns#
A robust set of patterns covers JWTs, AWS keys, private keys, and generic password fields – not just the word “password” followed by a colon.
package main
import "regexp"
// secretPatterns is a list of compiled regexes. Each regex must have a named
// capture group called "secret" that isolates the sensitive value to redact.
var secretPatterns = []*regexp.Regexp{
// AWS Access Key ID
regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16})`),
// AWS Secret Access Key (heuristic: 40 alphanumeric+/+ chars after known prefix)
regexp.MustCompile(`(?i)aws_secret_access_key\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=]{40})`),
// JWT token (three base64url segments separated by dots)
regexp.MustCompile(`(?P<secret>eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)`),
// PEM private key block
regexp.MustCompile(`(?P<secret>-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)`),
// Generic password= or password: assignment (covers config files, .env)
regexp.MustCompile(`(?i)(?:password|passwd|secret|api_key|token)\s*[=:]\s*(?P<secret>[^\s"']{8,})`),
// GitHub personal access token
regexp.MustCompile(`(?P<secret>gh[ps]_[A-Za-z0-9]{36})`),
}
// findSecret returns the matched secret substring and the full match start/end,
// or empty string and -1 if no pattern matches.
func findSecret(content string) (fullMatch, secret string, start, end int) {
for _, re := range secretPatterns {
loc := re.FindStringIndex(content)
if loc == nil {
continue
}
match := content[loc[0]:loc[1]]
// Extract the "secret" named group if present, else use the full match.
names := re.SubexpNames()
sub := re.FindStringSubmatch(content)
secretVal := match
for i, name := range names {
if name == "secret" && i < len(sub) {
secretVal = sub[i]
}
}
return match, secretVal, loc[0], loc[1]
}
return "", "", -1, -1
}The Redaction Logic#
Redact the secret, keeping the last few characters so the user can verify it matched the right value. Preserve surrounding context in the clipboard so copy-pasted config blocks remain usable.
package main
import "strings"
// redactSecret replaces most of the secret with asterisks, keeping up to 3
// trailing characters for user verification. Returns the modified content.
func redactSecret(content, secret string) string {
if len(secret) == 0 {
return content
}
visibleSuffix := 0
switch {
case len(secret) >= 12:
visibleSuffix = 3
case len(secret) >= 9:
visibleSuffix = 2
case len(secret) >= 6:
visibleSuffix = 1
}
redacted := strings.Repeat("*", len(secret)-visibleSuffix) +
secret[len(secret)-visibleSuffix:]
return strings.Replace(content, secret, redacted, 1)
}Thread-Safe Polling Loop with Context Cancellation#
The polling loop runs in a goroutine. Context cancellation provides clean shutdown (important for graceful reload when the service restarts).
package main
import (
"context"
"log"
"time"
"github.com/atotto/clipboard"
"github.com/gen2brain/beeep"
)
type Watcher struct {
interval time.Duration
previousContent string
}
func NewWatcher(interval time.Duration) *Watcher {
return &Watcher{interval: interval}
}
// Run polls the clipboard until ctx is cancelled.
// It is safe to call from a single goroutine only; clipboard access is not
// concurrent. Multiple watchers must not run simultaneously.
func (w *Watcher) Run(ctx context.Context) {
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("clipboard watcher: shutting down")
return
case <-ticker.C:
w.check()
}
}
}
func (w *Watcher) check() {
content, err := clipboard.ReadAll()
if err != nil {
// ReadAll can fail on Wayland/X11 if no owner; treat as empty.
return
}
if content == w.previousContent {
return
}
w.previousContent = content
_, secret, _, _ := findSecret(content)
if secret == "" {
return
}
redacted := redactSecret(content, secret)
if err := clipboard.WriteAll(redacted); err != nil {
log.Printf("clipboard watcher: failed to redact: %v", err)
return
}
w.previousContent = redacted
log.Printf("clipboard watcher: redacted a secret (%d chars)", len(secret))
_ = beeep.Notify(
"Clipboard Watcher",
"A potential secret was detected and redacted from your clipboard.",
"",
)
}package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Trap SIGINT and SIGTERM for clean shutdown.
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
cancel()
}()
log.Println("clipboard watcher: starting (interval=500ms)")
watcher := NewWatcher(500 * time.Millisecond)
watcher.Run(ctx)
}macOS: Running as a launchd User Agent#
On macOS, launchd user agents start on login without requiring a full system service or root privileges. Create a plist at ~/Library/LaunchAgents/com.manuelfedele.clipboard-watcher.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.manuelfedele.clipboard-watcher</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/clipboard-watcher</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/clipboard-watcher.log</string>
<key>StandardErrorPath</key>
<string>/tmp/clipboard-watcher.err</string>
<!-- Required for clipboard access on macOS Sonoma+ -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>Load and start it:
# Copy binary to a stable location
go build -o clipboard-watcher .
sudo cp clipboard-watcher /usr/local/bin/clipboard-watcher
# Load the launch agent (runs as the current user, not root)
launchctl load ~/Library/LaunchAgents/com.manuelfedele.clipboard-watcher.plist
# Check it is running
launchctl list | grep clipboard-watcherLinux: systemd User Unit#
[Unit]
Description=Clipboard secret watcher
After=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/local/bin/clipboard-watcher
Restart=on-failure
RestartSec=5s
# Required for clipboard access via X11 or Wayland
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority
[Install]
WantedBy=default.targetEnable and start:
systemctl --user enable clipboard-watcher
systemctl --user start clipboard-watcher
systemctl --user status clipboard-watcherBuilding and Code-Signing on macOS#
# Build for the current architecture
go build -o clipboard-watcher -ldflags="-s -w" .
# On macOS, ad-hoc sign for local use (no developer account required)
codesign --sign - --force --preserve-metadata=entitlements ./clipboard-watcher
# For distribution, sign with your Apple Developer ID
# codesign --sign "Developer ID Application: Your Name (TEAM_ID)" ./clipboard-watcher
# xcrun notarytool submit clipboard-watcher --apple-id ... --waitSecurity Considerations#
A clipboard monitor reads the contents of every item you copy: passwords, credit card numbers, personal messages, SSH keys, and anything else that passes through the clipboard. This is precisely the same access a malicious clipboard hijacker would have. The trust model is critical.
Key constraints to enforce:
- Run as the current user, never as root or a system service. A system-level service could read clipboard content from all users on the machine.
- Do not log clipboard content. The log lines in the example above log only the length of the redacted secret, not the value.
- Open source the binary you run. If you install a pre-built binary from an unknown source, you have no way to verify it is not exfiltrating everything you copy.
- Keep the binary in a user-writable location only you control (
~/binor/usr/local/binwith restricted write permissions).
com.apple.security.temporary-exception.pbselect entitlements to read the clipboard.wl-clipboard tool (wl-paste) provides a workaround for headless clipboard reading, but the atotto/clipboard package transparently uses xclip or xsel on X11 and may require wl-clipboard compatibility shims on Wayland sessions.Common Mistakes#
Polling too fast: CPU waste
Polling too slow: race condition
Running as root
/Library/LaunchDaemons) or a systemd system service (/etc/systemd/system) gives it root privileges and access to all users’ clipboard content. Always use ~/Library/LaunchAgents (macOS) or ~/.config/systemd/user (Linux) to scope it to the current user session.Logging clipboard content
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.