Shell History on macOS: zsh, bash and Terminal Sessions
macOS shell history in .zsh_history, .bash_history and Terminal's per-window session files: which commands a user typed and roughly when.
- Location
- ~/.zsh_history
- Proves
- Which commands a user typed in an interactive shell, in what order, and in which Terminal window
- Timestamps
- None by default; Unix epoch seconds only if EXTENDED_HISTORY (zsh) or HISTTIMEFORMAT (bash) is set
- Access
- File owner or root; plain files in the home folder
- Retention
- zsh default SAVEHIST=1000 lines; Terminal session files deleted after about two weeks
- Collection
- UAC, Aftermath, mac_apt
Tools
Compare all tools- mac_aptCLI · open source
- grepCLI · built into macOS
- sedCLI · built into macOS
What it is
Interactive shells write the commands a user typed to a plain-text history file in the home folder. Apple made zsh the default login and interactive shell for new accounts starting with macOS Catalina, so ~/.zsh_history is the main file on current systems. Accounts created on Mojave or earlier and later upgraded can still use bash (~/.bash_history), and users can switch shells with chsh at any time.
Apple's Terminal adds a second layer. The Apple-provided startup scripts /etc/zshrc_Apple_Terminal and /etc/bashrc_Apple_Terminal implement "Resume": each Terminal window or tab gets a TERM_SESSION_ID, and its commands are also saved into a per-session history file in ~/.zsh_sessions or ~/.bash_sessions. That makes it possible to attribute commands to a specific Terminal window, and sometimes recovers commands trimmed from the main file.
Where it lives
| File | Path | Notes |
|---|---|---|
| zsh history | ~/.zsh_history | Set by HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history in /etc/zshrc; zsh default since 10.15 Catalina for new accounts |
| zsh Terminal sessions | ~/.zsh_sessions/<TERM_SESSION_ID>.history, .historynew, .session | Created by /etc/zshrc_Apple_Terminal (folder mode 700) |
| bash history | ~/.bash_history | Default for accounts on 10.14 and earlier; macOS ships bash 3.2 |
| bash Terminal sessions | ~/.bash_sessions/<TERM_SESSION_ID>.history, .historynew, .session | Created by /etc/bashrc_Apple_Terminal |
| Root's history | /private/var/root/.zsh_history (or .bash_history) | Commands typed in a root login shell such as sudo -i |
| Startup files | /etc/zshrc, /etc/zprofile, ~/.zshrc, ~/.zprofile, ~/.zshenv, ~/.zlogin | Check for a changed HISTFILE, HISTSIZE, SAVEHIST or disabled history |
If ZDOTDIR is set, zsh looks for its files in that directory instead of $HOME. Other shells (fish, ksh, tcsh) keep their own history files. Home folders sit on the Data volume (/System/Volumes/Data/Users/<name>). The dotfiles themselves are not in a TCC-protected location, but a collector should still run with Full Disk Access.
What it proves
- The commands typed in interactive shells, in the order they were saved.
- Download-and-execute patterns (
curl ... | sh,chmod +x,xattr -d com.apple.quarantine), persistence setup (launchctl load, editing LaunchAgents), reconnaissance, archive creation and exfiltration commands. - With Terminal session files: which commands belong to which Terminal window, and when a session was last saved.
It does not prove:
- That a command succeeded, or what it printed.
- When a command ran, unless extended timestamps are enabled.
- Anything about non-interactive execution: scripts,
osascript, launchd jobs and remote tools that spawnsh -cdo not write history. Use the Unified Logs and process telemetry for those.
Key fields
Default zsh format: one command per line, no timestamps.
zsh with setopt EXTENDED_HISTORY:
: 1790000000:4;curl -fsSL https://example.test/a.sh -o /tmp/a.sh
| Part | Meaning |
|---|---|
: | Marker (zsh writes a no-op : command) |
1790000000 | Command start time, Unix epoch seconds |
4 | Elapsed time in seconds |
after ; | The command line |
bash with HISTTIMEFORMAT set writes a comment line #<epoch> before each command. Terminal .session files contain a line that echoes Restored session: followed by date -r <epoch>, where the epoch is the time the session state was saved.
Timestamps
- Default configuration: no per-command time at all. Only the file system times of the history file help: its modification time is roughly when a shell last exited and saved history (see APFS timestamps).
- zsh
EXTENDED_HISTORYand bashHISTTIMEFORMATstore Unix epoch seconds, UTC. Neither is enabled in Apple's default/etc/zshrcor/etc/bashrc. - Terminal session files: the epoch in
.session, plus the modification times of each.historyfile, give a per-window "last saved" time.
# Convert zsh extended history to readable UTC
sed -nE 's/^: ([0-9]+):([0-9]+);(.*)$/\1 \2 \3/p' .zsh_history |
while read -r ts dur cmd; do printf '%s %ss %s\n' "$(date -u -r "$ts" '+%F %T')" "$dur" "$cmd"; done
Retention
- Apple's
/etc/zshrcsetsHISTSIZE=2000(in memory) andSAVEHIST=1000(lines kept in the file). Older lines are trimmed as new ones are saved. - zsh appends history when the shell exits (
APPEND_HISTORYis on by default). A window killed hard, or a crash, can lose that session's commands. Options such asINC_APPEND_HISTORYorSHARE_HISTORYwrite each command immediately. - Terminal's session logic deletes files in
~/.zsh_sessions/~/.bash_sessionsolder than two weeks, checking at most once a day (_expiration_check_timestamp). - Users can disable or erase history:
unset HISTFILE,HISTSIZE=0,SHELL_SESSIONS_DISABLE=1(turns off only the Terminal session files, not history), a leading space withHIST_IGNORE_SPACE,history -cin bash, or simply deleting the file.
Collection
# Per user, preserving timestamps (run with Full Disk Access)
for h in /Users/*; do
ditto "$h/.zsh_history" "/cases/host01/$(basename "$h")/zsh_history" 2>/dev/null
ditto "$h/.zsh_sessions" "/cases/host01/$(basename "$h")/zsh_sessions" 2>/dev/null
ditto "$h/.bash_history" "/cases/host01/$(basename "$h")/bash_history" 2>/dev/null
ditto "$h/.bash_sessions" "/cases/host01/$(basename "$h")/bash_sessions" 2>/dev/null
done
sudo ditto /private/var/root /cases/host01/root_home
- UAC:
zsh.yamlandbash.yamlcollect history files, session folders, startup files and any customHISTFILEfound in them. - Aftermath: collects bash and zsh history,
.sh_history, and shell startup files including fish'sconfig.fish; no csh, ksh or fish history (root plus Full Disk Access). - mac_apt: the
TERMSESSIONSplugin reads bash and zsh session folders and history for every user.
Collect live before a user logs out if possible: open shells hold unsaved commands in memory only.
Parsing
History files are text, so cat, grep and sed do most of the work. Things to watch:
- zsh writes the file in its internal "metafied" encoding: bytes in the meta range are stored as
0x83followed by the byte XOR0x20. Commands with accented or non-Latin characters look garbled until you reverse that (drop0x83, XOR the next byte with0x20). - mac_apt
TERMSESSIONSmerges session.historyand.historynewfiles perTERM_SESSION_ID. - Quick triage for common attacker commands:
grep -nE 'curl|wget|base64|osascript|xattr|launchctl|chmod \+x|nc |python3? -c|/tmp/|/Users/Shared/' .zsh_history
Investigator tips
- Compare
.zsh_historywith each~/.zsh_sessions/*.history: session files are trimmed independently and can hold commands missing from the main file. - An empty or zero-byte history file with a recent modification time, or a symlink to
/dev/null, is a classic anti-forensics sign. Check startup files forunset HISTFILEorSAVEHIST=0. - Root shells write to
/private/var/root; pair them with sudo logs to see who elevated. - Commands that touch LaunchAgents or LaunchDaemons should be cross-checked against launchd plists and FSEvents for the matching file creation.
- Local APFS snapshots can hold older, longer versions of the history file.
/etc/zshrconly loads the session logic whenTERM_PROGRAMisApple_Terminal, so iTerm2, VS Code terminals and similar emulators write only to the main history file, never to~/.zsh_sessions.