zsh: The Z Shell
- 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 sh → ksh/bash → zsh, 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:
- Login vs non-login — a login shell is the first shell of a session (an SSH login, a console login). Opening a new terminal tab usually starts a non-login interactive shell.
- Interactive vs non-interactive — interactive means a human is typing at it; a script runs non-interactively.
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:
- Arrays are 1-indexed. In zsh
${arr[1]}is the first element (bash uses 0). zsh arrays also don’t auto-split on whitespace the way bash does. - No automatic word-splitting of unquoted parameters. In bash,
x="a b"; cmd $xpasses two arguments; in zsh it passes one. This makes zsh safer (fewer quoting bugs) but surprises bash users — opt in with${=x}if you really want splitting. - Unmatched globs are an error.
ls *.txtwith no matches prints an error in zsh (theNOMATCHoption) rather than passing the literal*.txtthrough as bash does.
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:deferannotation above loads a plugin lazily after the prompt appears, shaving startup further. If your shell still feels sluggish, profile it by addingzmodload zsh/zprofat the top of~/.zshrcandzprofat 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
- zsh is a Bourne-compatible shell that is far richer interactively than bash; it’s the default on Kali and macOS and the shell used in this course’s setup.
- Startup-file order is
.zshenv(always) →.zprofile(login) →.zshrc(interactive) →.zlogin— put interactive config in~/.zshrc, and only universally-needed env vars in~/.zshenv. - Mind the bash differences: 1-indexed arrays, no automatic word-splitting, and errors on unmatched globs — write scripts for bash/sh, use zsh for your interactive shell.
- zsh’s globbing + qualifiers (
**/*,*(.om[1])), completion system, shared history, and autosuggestions/syntax-highlighting plugins are the day-to-day wins. - Shell rc files are security-sensitive: they’re a persistence and PATH-hijack surface, and history is both evidence and a target.
References
- zsh manual (
man zshall) and the zsh website. https://zsh.sourceforge.io/Doc/ - A User’s Guide to the Z-Shell (Peter Stephenson). https://zsh.sourceforge.io/Guide/zshguide.html
- zsh startup files —
man zshmisc/ STARTUP FILES section. https://zsh.sourceforge.io/Doc/Release/Files.html - oh-my-zsh framework. https://github.com/ohmyzsh/ohmyzsh
- powerlevel10k prompt theme. https://github.com/romkatv/powerlevel10k
- zsh-autosuggestions and fast-syntax-highlighting plugins. https://github.com/zsh-users/zsh-autosuggestions
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-envrepo is the source of truth for the course shell environment (itsshell/tools/antidote_plugins.txtis the live plugin list); re-verify the~/.zsh_plugins.txtexample and powerlevel10k setup against it each term.