courses

zsh: The Z Shell

Why a whole page on a shell

The Shell and Other Basics page covers the mechanics every POSIX shell shares — PATH, redirection, environment. This page is about zsh specifically: the shell the course VM (Kali) ships as its default, and the one used in the recordings and provisioned by the canonical course environment — the Ansible playbook in the introsec-env repo. zsh is worth learning deliberately because it is far more capable interactively than bash — its globbing, completion, and history features genuinely speed up day-to-day work — while remaining close enough to bash that your scripting knowledge transfers.

This page is split into theory (what a shell is, how zsh starts up, how it differs from bash) and practice (the features you’ll actually use), with a note on the security relevance of shell startup files at the end.

Theory

What a shell is

A shell is two things at once: an interactive command interpreter (a REPL where you type commands and see results) and a scripting language. zsh, like bash, is Bourne-compatible — it descends from the original sh and runs the vast majority of sh/bash scripts unchanged — but it layers on a much richer interactive experience. The lineage is roughly shksh/bashzsh, each adding capability; just as bash displaced the older shells as the Linux default, zsh has become the default on macOS and Kali.

Interactive, login, and the startup files

The single most confusing thing about any shell is which configuration file runs when. It depends on two independent properties of the shell instance:

zsh reads its startup files in this order, skipping the ones that don’t apply:

File Read when Typical use
~/.zshenv always (every shell, even scripts) environment variables needed by everything; keep it minimal
~/.zprofile login shells run-once-at-login setup (analogous to .profile)
~/.zshrc interactive shells the big one: prompt, aliases, functions, options, completion
~/.zlogin login shells (after .zshrc) commands to run at the end of login
~/.zlogout login shells, on exit cleanup

The practical rule: interactive niceties (prompt, aliases, key bindings, plugins) go in ~/.zshrc; environment variables that even non-interactive scripts need go in ~/.zshenv. Putting heavy setup in ~/.zshenv slows down every script invocation — a common mistake.

How zsh differs from bash (the gotchas)

If you know bash, three differences will bite you at least once:

Because of these, it is good practice to put #!/bin/bash (or #!/bin/sh) on scripts and write them to that shell, and reserve zsh for your interactive environment.

Practice

Globbing that replaces find

zsh’s filename generation is its standout feature. Beyond * and ?, it adds recursive globbing and glob qualifiers — parenthesized codes that filter by file type, time, size, and more — letting you express in a glob what would otherwise need find:

**/*.py            # recursive: every .py file in this tree (** = any depth)
*(.)               # only regular files
*(/)               # only directories
*(.om[1])          # the single most-recently-modified regular file
*(.L0)             # zero-length (empty) regular files
**/*.log(.mh-2)    # .log files modified in the last 2 hours
print -l *(.om[1,5])   # the 5 newest files, one per line

Those parenthesized codes are glob qualifiers — filters appended in (…) that narrow matches by file type, timestamp, size, ownership, and ordering. The ones used above:

Qualifier Meaning
. plain (regular) files only
/ directories only
om order by modification time, newest first (Om = oldest first; other keys: n name, L size, a access time, c inode-change time)
[1] / [1,5] glob subscript — after sorting, keep only the 1st match, or matches 1 through 5
L match by size in bytes: L0 is exactly zero, L+10 is larger than 10, units k/m/g (e.g. Lm+1 = bigger than 1 MiB)
m match by modification time: mh-2 is “less than 2 hours ago”; unit letters M months, w weeks, d days (default), h hours, m minutes, s seconds; sign - = within / more recent than, + = older than

Qualifiers stack, left to right: *(.om[1]) reads “regular files, ordered newest-first, take the first” — i.e. the single most-recently-modified file. There are many more (by permission bits, owner, setuid/setgid, link count, even arbitrary test commands), all defined in the canonical reference: the zsh manual’s Glob Qualifiers section (also man zshexpn).

Combined with extended globbing (setopt extended_glob) you also get negation and approximate matching:

setopt extended_glob
ls ^*.txt          # everything EXCEPT .txt files
ls *.(jpg|png)     # alternation
ls (#i)readme*     # case-insensitive match

The completion system

zsh’s programmable completion is the reason many people switch. It is loaded once in ~/.zshrc:

autoload -Uz compinit && compinit     # initialize the completion system
zstyle ':completion:*' menu select    # arrow-key menu to pick among completions

It then completes not just filenames but command options, hostnames from ~/.ssh/config, git branches, package names, and more — press Tab and zsh knows the context.

History worth keeping

# in ~/.zshrc
HISTFILE=~/.zsh_history
HISTSIZE=50000 SAVEHIST=50000
setopt SHARE_HISTORY        # share history live across open shells
setopt HIST_IGNORE_DUPS     # don't record consecutive duplicates
setopt HIST_IGNORE_SPACE    # a leading space keeps a command out of history (handy for secrets)

HIST_IGNORE_SPACE is a small security win: prefix a command containing a token or password with a space and it never hits ~/.zsh_history.

Aliases, functions, and options

alias ll='ls -lah'
alias gs='git status'

# a function (can take arguments, unlike an alias)
mkcd() { mkdir -p "$1" && cd "$1"; }

setopt AUTO_CD          # type a directory name to cd into it
setopt CORRECT          # offer to fix mistyped commands
setopt INTERACTIVE_COMMENTS   # allow # comments at the interactive prompt

The prompt

zsh builds the prompt from PROMPT (left) and RPROMPT (right), with %-escapes for dynamic content:

PROMPT='%n@%m %1~ %# '      # user@host shortdir %  (# when root, % otherwise)
RPROMPT='%T'                # clock on the right

In practice most people use a theme rather than hand-rolling this. The course setup uses powerlevel10k, which renders a fast, informative prompt (git status, exit codes, timing).

Plugin management with antidote

Managing completion, syntax highlighting, and autosuggestions by hand is tedious, so a plugin manager handles it. The canonical course environment (the Ansible-based introsec-env repo) uses antidote — a fast, static-bundle manager. You list plugins, one per line, in ~/.zsh_plugins.txt, and antidote compiles them into a single static file it sources, so startup stays quick even with many plugins (unlike managers that re-resolve plugins dynamically on every launch). It can still pull in oh-my-zsh libraries and individual plugins through a compatibility shim:

# ~/.zsh_plugins.txt — antidote compiles this list into one static bundle
getantidote/use-omz                                     # enable the oh-my-zsh plugins below
ohmyzsh/ohmyzsh path:plugins/git                        # 'path:' pulls one OMZ plugin
Aloxaf/fzf-tab                                           # fuzzy, previewable tab-completion menus
zsh-users/zsh-autosuggestions                            # ghosted suggestions from history
zdharma-continuum/fast-syntax-highlighting kind:defer    # color commands as you type ('defer' = load lazily)
zsh-users/zsh-completions
ellie/atuin kind:defer                                   # searchable, synced shell history
romkatv/powerlevel10k                                    # the prompt theme

antidote is then loaded from ~/.zshrc, after compinit:

source ~/.antidote/antidote.zsh
antidote load ~/.zsh_plugins.txt

The two highest-value plugins are zsh-autosuggestions (proposes the rest of a command from history as you type — accept with →) and fast-syntax-highlighting (turns a command red when it isn’t a valid command, catching typos before you hit Enter); fzf-tab upgrades tab-completion into a fuzzy, previewable menu.

ℹ️ Plugin managers differ mainly in when they resolve plugins: antidote (and zinit) precompile a static bundle for fast startup, whereas older dynamic managers re-resolve on every launch. The kind:defer annotation above loads a plugin lazily after the prompt appears, shaving startup further. If your shell still feels sluggish, profile it by adding zmodload zsh/zprof at the top of ~/.zshrc and zprof at the bottom.

Security relevance

Shell startup files are a quiet persistence mechanism: a single line appended to ~/.zshrc (or ~/.zshenv, which runs even non-interactively) runs every time you open a shell. An attacker who can write to your home directory can backdoor your environment, hijack PATH (see the PATH-order warning), or alias sudo to capture your password. When investigating a host, the rc files are on the checklist — and HISTFILE is both forensic evidence and something attackers clear (the unset HISTFILE / space-prefix tricks cut both ways). Treat your dotfiles as security-sensitive, version-control them, and review changes.

Key takeaways

References


Related course pages: Shell and Other Basics · Software Configuration · tmux · Navigation Basics · Host Security · Unix Text Processing: sed and awk

🛠️ Maintenance note: zsh core behavior is stable, but the plugin ecosystem moves — managers (antidote/zinit/oh-my-zsh) and exact plugin names change over time. The introsec-env repo is the source of truth for the course shell environment (its shell/tools/antidote_plugins.txt is the live plugin list); re-verify the ~/.zsh_plugins.txt example and powerlevel10k setup against it each term.