Understanding the command injection risk behind --preview and --bind, how to quote placeholders correctly so items are never executed as code, and keeping command history and output private so they don't leak into logs. Including standards for writing fzf configuration that's safe to share with a team.

In episode 13 you started running fzf on servers over SSH and executing commands on selected items. That's exactly where fzf's power also turns into risk: the more fzf runs commands for you — preview, execute, become — the bigger the surface where unfriendly input can sneak in. This episode is not a horror story, but concrete technical lessons: where the risk comes from, how injection happens, and which habits save you.
We'll cover three areas. First, command injection behind --preview and --bind — how an innocent-looking item can be executed as shell code, and how to quote placeholders correctly. Second, privacy and hygiene — preventing history full of secrets from appearing in Ctrl+R and making sure fzf output doesn't leak into logs. Third, standards for writing fzf commands that are safe for a team.
Recall how fzf works, built up from the early episodes: it receives a text list on stdin, filters it, then prints the selection to stdout. The attack surface appears when that text joins a shell command. The two places most often affected:
--preview 'command {}' — the command reruns every time the cursor moves, with {} replaced by the currently highlighted item's text.--bind 'key:execute(command {})' or become(command {}) — the command runs when a key is pressed, with the same {}.In short: fzf replaces {} with the item's raw text, then hands the whole string to the shell. If the item contains characters meaningful to the shell — spaces, semicolons, $(), backticks — the shell interprets them as code, not just data.
Important
The item source can never be trusted. File lists can contain unusual names, command history can contain anything, and another program's output can slip in strange text. Treat every item as unverified data, not as a safe argument.
Let's prove it with the smallest possible example. Imagine a directory containing a file with the following name:
touch 'foo;touch /tmp/pwned.txt'
lsfzf --preview 'cat {}'As soon as the cursor highlights the file foo;touch /tmp/pwned.txt, fzf assembles the command string cat foo;touch /tmp/pwned.txt and hands it to the shell. The shell runs cat foo (an error, that file doesn't exist), then executes touch /tmp/pwned.txt. From your perspective, the preview just looks like an "error" — but behind the scenes a command has run. Replace touch with rm -rf or a command that phones home, and the impact is clear.
The good news: the defense is simple — quote the placeholder so the shell treats the item as a single literal argument. By wrapping {} in single quotes inside the preview command, the item's contents no longer have meaning to the shell:
fzf --preview "sed -n 1,50p '{}'"The command fzf assembles becomes sed -n 1,50p 'foo;touch /tmp/pwned.txt' — all characters inside the single quotes are treated as pure data. This isn't a total cure (file names containing single quotes can break through), but it closes nearly every real vector.
Warning
Quoting the placeholder is the first layer of protection, not a license to let your guard down. For lists full of surprises, add a second layer: filter items before they enter fzf — for example, reject items containing control characters or globs. And for destructive actions (rm, restart, drop), make a habit of showing a preview then asking for confirmation, or running a dry run first.
The principle deeper than quoting: separate data from code. The more complex your fzf commands, the easier this rule is to break. Two patterns help:
Don't paste program output into a command. Instead of --bind 'enter:execute(rm $(cat {q}))', let fzf only select, then consume the result with a tool that handles arguments explicitly — like xargs -0 or quoted shell parameters.
Use the null delimiter for file lists. Null-separated items cannot be split by spaces or newlines, so output consumers (xargs -0, while IFS= read -r) receive one intact item per argument. This is the same pattern as --read0 and --print0 from episode 6, and here it doubles as a safety measure.
fd -t f -0 | fzf --multi --print0 | xargs -0 -I{} file {}Notice that file {} still uses a placeholder — but now {} is an item that already passed the -0 filter, and its reader (xargs -0) guarantees no argument splitting.
Ctrl+RNow let's move to the privacy side. Ctrl+R from the shell integration reads the command history — and that history often contains things that should never be displayed: API tokens, passwords that were already typed, database connection strings. Every time you open Ctrl+R on a shared screen or during a demo, that list becomes a public display.
A layered defense for this problem:
| Layer | Method | Effect |
|---|---|---|
| Don't store | HISTCONTROL=ignorespace + a space at the start of the command | Commands starting with a space don't enter history |
| Don't record | HISTIGNORE containing secret patterns | Matching lines aren't saved to history |
| Don't display | A custom binding that filters history | Suspicious items don't appear in fzf |
For the last layer, replace the Ctrl+R invocation with a widget that removes lines that look like they contain credentials before they enter fzf:
__history_clean() {
builtin fc -lnr -1000 |
grep -Ev '(pass(word|wd)|token|api[_-]?key|secret|BEGIN (RSA|EC|OPENSSH) PRIVATE)' |
fzf --height 40% --layout reverse
}
bind -x '"\C-x\C-r": __history_clean'Replace the regex patterns with keywords relevant to your environment. This isn't complete security — pattern-based filtering can miss — but it closes the most common leak: secrets accidentally shown on screen.
The second leak is subtler: fzf output that gets recorded along the way. Two common paths:
The preview reads sensitive files. --preview 'cat {}' on a config file list can display secret-containing contents on screen, and if your terminal records scrollback or the output is redirected to a log, the contents get stored along with it.
Unfiltered stdout output. If fzf's result is piped straight into a script that writes logs (for example, a list of commands that were run), secrets get written too. Filter with grep or sed before it reaches the log.
Habits that help: limit the preview to metadata (size, time, git log) rather than full contents; hide the preview for certain directories; and before piping output to a log, test once what actually comes out of fzf's stdout.
Tip
One of the golden operational rules: sensitive commands are never typed on the command line. Use a secret manager or environment variables injected at runtime, not tokens baked into history. That way, securing Ctrl+R and the logs becomes much easier — because there's nothing to hide from the start.
When fzf configuration is shared with a team — via dotfiles, bootstrap scripts, or plugins — one person's mistake becomes everyone's mistake. The standards I recommend:
--preview, execute, and become. Always, without exception. It's a one-sentence rule that's easy to review.execute with unverified input. If an action must run a command, make sure the item passes validation first, or use an action that accepts arguments explicitly.FZF_DEFAULT_OPTS is easier to spot in a PR diff than in a private .zshrc.FZF_DEFAULT_COMMAND or in history files that get synced to a repo.| Mistake | Risk | Mitigation |
|---|---|---|
Preview without quoting {} | Command injection | Quote the placeholder: "sed -n 1,50p '{}'" |
execute using {q} without validation | User query executed as code | Validate first, or avoid execute from {q} |
xargs without -0 on file names | Arguments split, wrong command | fzf --print0 | xargs -0 |
| Secrets typed on the command line | Appear in Ctrl+R and logs | Secret manager + HISTCONTROL + HISTIGNORE |
| Preview displaying sensitive files | Contents leak to scrollback/logs | Preview metadata, not full contents |
| fzf configuration without review | Mistakes spread to the team | Keep it in a repo, quote placeholders in all actions |
Caution
This episode teaches respect for one rule: data never becomes code automatically. Every time you write --preview, execute, or become, ask one question: "could the contents of {} be different from what I expect?" If the answer is "yes", quote, filter, or avoid. That small habit is what separates a safe setup from one just waiting for a bad day.
In this episode 14 you understood the dark side of fzf's power and how to close it: the attack surface behind --preview and --bind where items can turn into shell code; placeholder quoting as the first defense and separating data from code as the deeper principle; command history privacy via HISTCONTROL, HISTIGNORE, and a Ctrl+R widget that filters secrets; and configuration standards safe to share with a team.
What to take home: fzf is a filter, and a filter must never execute anything you haven't asked for. With that rule, you can use the power of --bind without fear.
And that's the next chapter. In episode 15 we cover custom keybindings & actions — accept, execute, execute-silent, become, reload, change-preview-window, preview-top, select-all, toggle+, up to --expect and --no-clear. This time you'll build actions that are truly personal: opening editors, git add, even killing processes — all from within a single fuzzy interface.