top of page

Shell Games -or- Why Jamf Admins Should Switch to Zsh

What Happens in Vegas…

Back in Spring of 2000, I was planning a trip to Vegas with a friend. In preparation I was visiting the site which still exists, but has naturally changed a great deal. I have two screenshots of the way it looked below.

Most notably, in those days there were two webcams that could be controlled by a visitor. One was at the Imperial Palace and overlooked that part of the Strip. Directly across from it was Caesar’s Palace and a little domed entrance to the Forum Shops.

When one was controlling the camera, there were pan/tilt/zoom settings that could be either controlled by clicking or by entering the values into the form. It turns out you could also put those values in the URL query and it would go directly there. This was, I believe, my third trip there so I had a decent rundown in my head of what was where in the real world, so I had an idea. I created a script to direct the camera to a location I had mapped out in my head, parse the returned HTML for the URL of the resulting image, download the image, and place it on another webpage I maintained. So when I was there, I went to the Forum Shops and connected my Nextel phone to my Palm V PDA with a data cable and took the following selfie with the internet and a shell script.

Yup. That’s me standing pretty much center frame. But wait… what’s that on my shirt? Enhance! ENHANCE!

I was wearing my “Bourne Again Believer” shirt from the now defunct website Now I didn’t just tell you that story to show off my level of scripting nerdity. Although these days, I get fewer and fewer chances to tell it, so… The main point is that I’ve been a bash scripter for over 20 years. So making the change from bash as my default shell to zsh was not an instant hit for me. It took some convincing. I am hoping that by the end, you’ll be ready to make the change as well.

The Old Switcharoo

So what’s all this about? Well with the release of macOS Catalina (10.15), Apple has made zsh the default shell for new users. Prior to that, bash had been the default shell since macOS 10.3 (aka ‘Panther’) and for macOS versions 10.0 through 10.2 it had been tcsh.

Timeline of the default shells is macOS

So for a large percentage of macOS users, bash had always been the default and changes like this can be a bit jarring.

Why Did Apple Make the Change?


I was unable to find any official answer to that question, and there are two prevalent theories that you'll find on the internet. First is that it was related to Shellshock, the exploit found back in 2014 in bash that would allow an attacker to execute code on your computer. This exploit was present in all versions of bash going back to version 1.0.3 and right through 4.3. This meant that every single Mac was potentially a target. While remote exploitation was unlikely as the macOS didn’t expose these features by default, an attack could be hidden inside another application. Apple had a patch out very quickly, and I never had any clients with known issues.


But the most likely reason for the change was that while the version of bash present on the Mac (v3.2.57) was released on the GPLv2 open source license, starting with bash v4+, it was released under GPLv4 which was often considered “corporate hostile” which I’m sure didn’t sit well with Apple.

And for what it’s worth, the version of bash currently on your Mac is v3.2.57, which is the exact same version that has been on your Mac even before the change to zsh. Conversely, tcsh, which hasn't been the default shell in over 20 years is version 6.21.00 which is the version that was current as of the release of Ventura.

Why Should Jamf Admins Change?

So that’s (probably) why Apple changed. But why should Jamf Admins change?

I could go on and on about all the community plugins (like OhMyZsh) that exist to make zsh a wonderfully customizable environment for daily use. But that’s not us. Few, if any of us, spend our entire day in the Terminal and need or want to make it shiny and bright. Don’t get me wrong. I like shiny and bright. And I do spend a lot of time in the Terminal. But this is about what can zsh do for you as a Jamf Admin. And that means deploying scripts to our Macs to assist in automation and management. To that end, I only care about what zsh provides for every Mac, not just the ones I log into personally. And for Jamf Policy Scripts, there are two major highlights:

Real Arrays

If you’ve ever tried to pull in a list of files with spaces in the name, or fields from an API, or results from a subcommand, you know that by default bash3 looks at each word is a separate element. Any form of whitespace (space, tab, newline, etc.) is a separator. So if you run the code below:


is the 
test data 
from hell

for line in ${LIST}; do
    echo "Line: ${line}"

The output is:

Line: This
Line: is
Line: the
Line: test
Line: data
Line: from
Line: hell

And there are tricks you can do with the Internal Field Separator (IFS) you can get the script to break based on the newlines instead. But you have to remember to set it back afterwards or you get unexpected results elsewhere in your script. But with zsh, you just add a variable flag (‘f’) and it automatically separates on the newline with no extra work. Take a look at line 10 (the line just above the for loop) below:


is the
test data from

for line in ${LIST}; do
    echo "Line: ${line}

The outer parenthesis specify to zsh that this is an array and the ‘f’ in the inner parenthesis sets the field separator to newlines. And now the output is what we wanted in the first place:

Line: This
Line: is the
Line: test data from 
Line: hell

And there are other expansion flags to simplify all those things we do like changing strings to upper or lower case or perform simple search/replace actions. But on the subject of arrays, we also have associative arrays. These are the key/value pairs we get in programming languages like C and Python. Let’s say I want to have a series of variables that refer to how my script is configured by a Jamf policy. I could come up with a naming scheme like: $configTime, $configAskReason, $configAPIHash so I can tell what they do at a glance. Or better/worse, I can (and we do) make all policy variables all uppercase like in the following snippet from a previous version of our Breakglass Admin script.

Associative Arrays

Most notably, in those days there were two webcams that could be controlled by a visitor. One was at the Imperial Palace and overlooked that part of the Strip. Directly across from it was Caesar’s Palace an d a little domed entrance to the Forum Shops. TIMEMIN=$( [ $4 ] && echo $4 || echo "5" ) ## defaults to 5 minutes if not specified
TIMESEC=$(( ${TIMEMIN} * 60 ))

## $5 = Ask for reason (y/n)
## If true, the user will be prompted with an AppleScript dialog why the \dxcxccccd admin rights and the reason
## will be echoed out to the policy log.

## $6 = API user hash*
## 	*hash = base64 encoded string of 'user:password' for an API user for the next two options
##		Note: Yes, I know this isn't a 'hash' but the word is more concise
## 	If provided, this string will be used in an API call to file upload the logs at the end
if [[ ${APIHASH} ]]; then
	JSSURL=$(defaults read /Library/Preferences/com.jamfsoftware.jamf.plist jss_url)
	SERIAL=$(system_profiler SPHardwareDataType | awk '/Serial Number/ {print $NF}')
	RECORD=$(curl -s -H "Authorization: Basic ${APIHASH}" -H "Accept: text/xml" "${JSSURL}/JSSResource/computers/serialnumber/${SERIAL}")
	COMPID=$(echo "${RECORD}" | xmllint --xpath '/computer/general/id/text()' -)

## $7 = Upload logs to Jamf (y/n)
## If yes, the system logs for the duration of elevated rights will be attached to the computer record in Jamf
## Note: The API user 'hash' above must be provided above and that user must have the following permissions:
##		Computers - Create
##		File Uploads - Create Read Update

But in Python I would make a dictionary (associative array) to store these as key value pairs. And with zsh (or Bash4+) I do the same like this:

## Configuration Options and Defaults
## An empty value indicates false or no default.
declare -A CONFIG
  [timemin]=5              # Time (in min) for admin rights
  [askreason]=''           # Ask user for reason - uses Applescript
  [uploadlog]=''           # Upload logs to Jamf (y/n)
  [removegroup]=''         # Name of static group from which to
  [basicauth]=''           # Base 64 encoded "user:pass" for an api
  [action]="promote"       # Alternative is "demote"
  [demotetrigger]="demote" # Custom trigger for demotion policy

Now I can reference ${CONFIG[timemin]) which is cleaner looking but still tells me at a glance exactly what it is for.

Still not convinced? What about this… most of us get ideas and solutions to our scripting needs from the internet. Bash 4 has been around since 2009. Bash 5 since 2019. And while Apple may be frozen at Bash 3, the rest of the world is moving on. More and more solutions I find on the internet to solve my needs require a newer version of Bash. Or just a move to zsh.

What Changes Need to be Made?

Shebang Line

The what?! The ‘shebang line’ is the top line in a shell script that starts with ‘#!‘ and tells the computer which interpreter to use to process the script. For bash scripts it’s:


And for zsh it's:


So that’s easy enough. Is there anything else I need to change? For 99% of your scripts, no. That’s the only change to your existing code you need to make. But if you are using arrays in your bash scripts, there’s one huge change you need to be aware of.

Arrays Now Start at 1

Bash follows C and many other programming languages and uses ‘0’ as the position of the first element in an array. So if you are running a loop across an array that is ’N’ elements long, the loop goes from 0 to N-1. Consider this bit of bash script:

## Build the password by shuffling the bits
while [ ${#ALL[@]} -gt 0 ]; do
	i=$(jot -r 1 0 $(( ${#ALL[@]}-1 )))
	ALL=( ${ALL[@]/${ALL[$i]}} )
NEWPASS="$(printf '%x' ${passArray[@]} | xxd -r -p)"

Line 4 (beginning with "i=") picks a random number as low as zero (the first position of the array) and has to calculate the total size of the array and then subtract one. Whereas the zsh version starts at 1 and goes to the last element. Here are the two lines for comparison:


i=$(jot -r 1 0 $(( ${#ALL[@]}-1 )))


i=$(jot -r 1 1 ${#ALL[@]})

Want to Know More?

Update: When I first wrote this article and started preparing to present at our monthly meetup, I had a number of sites collected and bookmarked with pieces of all of this spread around. But while making my presentation, I found Armin Briegel's eight part series from 2019 on the exact topic of moving to zsh on his site,

So rather than send you down a series of various websites of varying quality and depth, I’ll just point you to Armin. I’ve learned a lot from his site in the past and I’m surprised I didn’t find this series earlier. But then, I probably wasn’t looking for it. Take a look at his site. This is the man who literally wrote the book on "moving to zsh”.

216 views0 comments