Scripting macOS, part 4: Running the Script

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week over the summer. Enjoy!

Running the Script

Now that we have the code for a functional (even though minimal) script, you can run it from the command line with:

> ./hello.sh

(Verify that the current directory is your script project folder.) You probably have wondered why you need the ./ before the script name. When you type the script name without the ./ it will fail:

> hello.sh
zsh: command not found: hello.sh

What does the ./ mean and why do we need it?

Current Working Directory

In the shell, the . character represents the current working directory or the directory that you ‘cd-ed’ to. Usually, the working directory is shown in the window title bar of Terminal and in the shell prompt (the status text shown before your cursor in the Terminal window). You can also have the system show your current working directory with the pwd command:

> pwd
/Users/armin/Projects/ScriptingMacOS

When you type ./hello.sh the shell will substitute the current working directory for the . character to get

/Users/armin/Projects/ScriptingMacOS/hello.sh

Your path will of course look different.

This means that when I use the ./hello.sh form, the shell knows exactly which file I want to execute and where to look for it. There is no ambiguity.

Now we know what the ./ means, but why is it necessary?

Finding Commands

A command you enter in the shell is just a string of text to begin with. The shell has to parse this text into pieces to determine what needs to be done.
We will use the chmod command from earlier as an example:

> chmod +x hello.sh

The text you entered is ‘chmod +x hello.sh’. The shell will split this text into pieces on the spaces (or tab characters). This will yield three elements:
chmod,’ ‘+x,’ and ‘hello.sh

The shell is only really interested in the first element. The shell will try to interpret the first element of your entry as a command.

Note: Many programming languages start counting at zero. This first element is also called ‘argument zero’ or $0.

The remaining elements are arguments, which the shell will pass on to the command. Arguments are optional. Not all commands require or even have arguments.

Note: This is a simplified description of the parsing process in shells. The reality is quite a bit more complex. But this description is ‘close enough’ for most situations. We will explore some of the nuances later.

If you are interested, you can get all the details in the shell’s documentation:

  • Zsh Documentation: Shell Grammar
  • bash man page, search for ‘Command Execution’

When there is a ‘/‘ character anywhere in the first element, the shell will interpret the first element as a full or relative path to an executable file and attempt to run that.

This is the behavior we are using when we type ./hello.sh. Since that contains a ‘/‘ the shell will resolve the path and execute our script.

We are intentionally using the seemingly redundant ./ prefix to tell the shell to run the script from the current working directory.

When the first element does not contain a ‘/,’ the shell will check if it is one of the following:

  • a shell function
  • a shell built-in command or reserved word
  • an external command

Shell Functions

Shell functions are (mainly) customized shell behavior declared by the user. They look and work like commands.

Note: If you are interested in customizing your shell environment, you can find details in my books “Moving to zsh” and “macOS Terminal and Shell.”

Shell Built-ins

Shell built-in commands cover tasks that are either inherent to the shell or can be performed much faster within the shell than as an external command. These are commands that affect the internal state of the current shell process like cd, alias, or history, or commands that are simpler and faster to implement as built-ins like echo or read.

You rarely need to worry about whether a command is a built-in.

Many built-in commands will have an executable external command file as well. This serves as a ‘fallback’ for the rare situation that the shell built-in is not available.

You can use the (aptly, but confusingly, named) command built-in to determine if a command is built-in or external:

> command -V cd
cd is a shell builtin
> command -V sw_vers
sw_vers is /usr/bin/sw_vers

External Commands

When the command entered is neither a function, nor a built-in, and does not contain a ‘/,’ then the shell will go search for an executable file with that name.

The PATH environment variable determines the locations in the file system and the order in which to search them.

The default PATH on macOS in an interactive shell is:

/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

The PATH variable contains a colon-separated list of directories. When you have third-party software installed, or customized your shell configuration, your shell may have additional directories. The default PATH splits into the following five directories:

/usr/local/bin
/usr/bin
/bin
/usr/sbin
/sbin

Note: Variable names in the shell are case-sensitive. The variable names MY_VAR and my_var represent different variable and values.
Zsh, however, has the concept of ‘connected variables.’ In zsh, PATH and path are connected variables. The upper-case PATH contains the colon-separated list of directory paths, while the lower-case path is an array of paths. The actual list of directories will be the same and changing one variable will change the other. Zsh has this concept to maintain compatibility with other shells with the PATH, while also allowing you to use array operators on the path.
I will use the colon-separated PATH, because it is more compatible with other shells.

When a user enters a command like this:

> system_profiler

This is neither a function or alias, nor a built-in command. It also does not contain a ‘/.’

The shell will check for the presence of an executable file with the name system_profiler in all the directories given in the PATH. It will start with /usr/local/bin, then /usr/bin, then /bin, until it finally finds a matching file in /usr/sbin.

Then the shell will attempt to execute /usr/sbin/system_profiler.

When no matching files can be found in any of the directories listed in the PATH, the shell will present a ‘command not found’ error.

> cantFindMe
zsh: command not found: cantFindMe

If you are curious which file the shell will use for a given command you can use the command or the which tool:

> command -V system_profiler
system_profiler is /usr/sbin/system_profiler
> which system_profiler
/usr/sbin/system_profiler

PATH precedence

Once the shell finds a matching file, it will stop searching the remaining paths. If there were a second matching executable in /sbin, the shell would never find and execute it. The order of the directory paths given in the PATH variable determines the precedence.

We can test this by placing an executable with a file name matching an existing command in /usr/local/bin. Since /usr/local/bin comes first in the default interactive PATH, the shell should prefer our executable over the default command.

> sudo ditto hello.sh /usr/local/bin/system_profiler

You need administrator privileges to modify the contents of /usr/local/bin. The ditto command preserves all the file’s metadata (like privileges and extended attributes), which makes it preferable to cp for this task.

Then open a new Terminal window. You need to do this because every shell instance will cache or ‘remember’ the lookup for a command to speed up the process later. The shell instance in your current terminal window will remember the last lookup for the system_profiler command. A new Terminal window will start a new ‘fresh’ shell instance, forcing a new lookup:

> which system_profiler
/usr/local/bin/system_profiler
> system_profiler
Hello, World!

Obviously, overriding a system provided command this way can break your workflows and scripts. To be safe, remove our script from /usr/local/bin right away:

> sudo rm /usr/local/bin/system_profiler

There are some situations where overriding a system-provided command is desirable, though. For example, you could install the latest version of bash 5 as /usr/local/bin/bash. And then, when you invoke bash from your interactive terminal, you will launch that version, instead of the outdated version that comes with macOS.

Note: You cannot simply overwrite /bin/bash with the newer version on macOS. The /usr/bin, /bin, /usr/sbin, and /sbin folders are protected by System Integrity Protection and the read-only system volume.

When bash is invoked with the absolute path /bin/bash, the path will need to be updated to use the newer version, though. This includes the shebangs in scripts, and the UserShell attribute in a user’s account record for their default shell.

Extending your tool set

As you get more confident and experienced with scripting, you will assemble a set of scripts that you will use regularly. When you use a script often, it would be nice if the shell recognized them as commands, without having to type the path to them.

To achieve that you can add the directory containing the scripts to the PATH variable. I put my frequently used tools in ~/bin. The name is chosen to be somewhat consistent with the four standard locations for tools, but really does not matter. You can append your tool directory to the PATH with:

> export PATH=$PATH:~/bin

This reads as: replace the value of the environment variable PATH with its current value and append :~/bin. You can verify the new value with

> echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/armin/bin

We added our custom directory to the end of the PATH variable, so you cannot accidentally override system tools.

To prevent other local users from changing your command files, you should set the directory’s privileges, so that only you can access:

> chmod 700 ~/bin

Now you can copy our hello.sh script to that folder. We will rename it to just hello, so it looks more like a command:

> ditto hello.sh ~/bin/hello

Then you can run your script just by typing

> hello
Hello, World!

Most of the scripts you build will not need to be as accessible as this.

Since you do not want to manually change the PATH every time you create a new Terminal window, you should add this line to your shell configuration file:

export PATH=$PATH:~/bin

Context Matters

Leveraging PATH to find and possibly override commands can be a useful and powerful tool. However, when scripting, you have to always remember, that the interactive Terminal environment may not be the environment that your script will run in ‘production.’

This is especially important for scripts run by

  • LaunchDaemons or LaunchAgents
  • AppleScripts
  • applications other than Terminal (such as Xcode)
  • installer packages
  • management systems

When run from any of these environments, the environment the script runs in will be different from your interactive shell environment. Most likely the PATH in any of these environments will be set to the minimal four system folders:

/usr/bin:/bin:/usr/sbin:/sbin

This should work well for most commands and tools. But when you are working with third party software, especially newer versions, then you need to pay very close attention.

When you build scripts for these environments, I recommend setting the PATH at the beginning of your script explicitly to the value you need:

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

When your scripts require tools, include their locations in the PATH. This way, the PATH environment is declared explicitly at the beginning of the script and there can be no confusion.

For the scripts in this series, the default PATH will be sufficient, so we will not need to worry about this.

Next: Lists of Commands

Scripting macOS, part 3: The Code

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week over the summer. Enjoy!

The Code

A script file is a text file with the executable bit set.

You can set the executable bit on any file. That alone does not turn it into a working script file. To get a working script file, the contents need to have the right structure or ‘syntax.’

Our example above is close to the minimum you need to have a working script file. We will look at the contents one line at a time.

#!/bin/zsh

# Greetings
echo "Hello, World!"

Shebang

The first line in our script is:

#!/bin/zsh

The first two characters #! form a special file signature or ‘magic number.’ They tell the shell and the system that this is not just any file, but a script file that should be interpreted.

These two characters have special names. The number sign is called the ‘hash’ and the exclamation mark is called the ‘bang.’ Together they are the ‘hashbang’ or the ‘shebang.’

The shebang characters have to be followed by the full path to the interpreter binary. There are no spaces in between the shebang characters and the interpreter path.

In this file, we have designated the zsh binary /bin/zsh.

When you type ./hello.sh to execute your script in Terminal, the shell will check if the file is executable. If it is executable, the shell will look at the first characters of the file. It ‘sees’ the shebang #! characters, and determines from them that this is a script file. Then it will read the rest of the first line to determine the interpreter. It will then hand over the entire script file to that interpreter binary.

This will have the same effect as running your script like this:

> /bin/zsh hello.sh

A new zsh process starts which reads and interprets the script file.

It does not matter which shell you use as your interactive shell. The shebang determines the interpreter for the script. That means you can run bash and sh scripts from zsh and vice versa.

When you are writing your scripts against sh or bash, you will have a shebang line of #!/bin/sh or #!/bin/bash, respectively.

The three shells are pre-installed as part of macOS and protected by System Integrity Protection and the read-only system volume. When you install newer versions of the interpreters, such as bash v5, then that binary will be in a different location. Most commonly /usr/local/bin.

If, for example, you have bash v5 installed as /usr/local/bin/bash, the shebang lines in the scripts that should use bash v5 will be:

#!/usr/local/bin/bash

When you then try to run these scripts on a mac that does not have bash v5 installed (or installed in a different location) then the scripts will fail.

> ./hello.sh
zsh: ./hello.sh: bad interpreter: /usr/local/bin/bash: no such file or directory

This can also happen when you run your script on a different platform. Even though the other platform may have the bash or zsh binary installed, it could be at a different path than on macOS. And the interpreter binary may be a different version than on macOS (especially with bash) which can lead to the script being interpreted differently.

For scripts that need to work in many different environments you will often see a shebang that uses the env command:

#!/usr/bin/env bash

The env command will use the current user’s environment, especially the PATH, to determine where the bash binary is and passes the script on to that. This will make your shebang more flexible, but you also give up some control as the environment will differ from system to system and from user to user.

For situations where you need control—such as management scripts—a fixed path for the shebang should be preferred, but then you also have ensure that the interpreter binary will be available at that path.

Whitespace

The line right after the shebang is empty. In shell scripts (and most other programming languages) empty lines are just ignored. Adding empty lines will make your code more readable.

Comments

The third line in our script starts with a number character or ‘hash.’

# Greetings

In shell scripts, lines that start with the hash character are ignored by the interpreter. You can and should use this to leave explanations and comments in your code.

Use comments in your code to leave explanations and notes.

echo Command

Now, finally, we will get to the actual code. The part of the script that does something.

echo "Hello, World!"

The echo command is one command that you rarely use in the interactive shell, but very frequently within scripts. It tells the shell to print (repeat or ‘echo’) text to the terminal’s output.

As usual in shells, the text we pass in to echo as an argument is ‘held together’ by quotes. This kind of text used in code is also called a ‘string’ because it is a ‘string of characters.’

Next: Running the Script

Scripting macOS, part 2: The Script File

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week over the summer. Enjoy!

The Script File

In the previous part, we created a text file and named it hello.sh. As we have mentioned before, all shell script files are text files.

File Extension

The file extension .sh is entirely optional. When you rename the file to merely hello it will remain a functional script:

> mv hello.sh hello
> ./hello
Hello, World!

When you use the script frequently as a command line tool, typing the additional .sh is cumbersome and does not ‘fit in’ very well with all the other command line tools. It is very common to remove the file extension in these cases.

However, the .sh file extension tells Finder and other applications what kind of file this is. You can assign the .sh file extension to be opened in your favored text editor when you double-click the file in Finder.

To do this, rename the file so it has the extension again:

> mv hello hello.sh

Then select the script file in Finder and open its Info panel (⌘I).

In the ‘General’ section of the info panel, you can see that Finder recognizes this file extension as a shell script.

In the ‘Open with’ section you can tell Finder which application to use to open this file. The popup list will show all applications you have installed that can open .sh files. Select your favorite text editor.

To tell the system to open all files with a .sh extension with your favorite text editor, click on the ‘Change All…’ button here. This will set your favorite text editor as the default application for the .sh file extension.

Some people like to use .bash and .zsh file extensions to further distinguish scripts written specifically in bash or zsh. While this can be useful in some workflows—especially when translating scripts from bash to zsh—it is generally not necessary. The .sh file extension works well for all these scripts. The .bash and .zsh file extensions are also not automatically recognized by the Finder or text editors as shell scripts.

Executable Bit

Aside from the actual script code, a shell script requires one more thing that distinguishes it from any other text file. You have to the tell the system that this file can be executed as a command.

This information is stored in the executable bit of the file privileges or file mode. You can see this information with the long form of the ls command:

> ls -l hello.sh 
-rwxr-xr-x@ 1 armin  staff  hello.sh

The file mode is displayed in the first ten characters in this output: -rwxr-xr-x.

The leading dash designates this item as a file. (A directory will show d, a symbolic link will show l, etc.)

The file access privileges are shown next. There are three sets of three flags or bits. The first three (in this case rwx) are the privileges for the file’s owner. The second set (r-x) are the privileges for group access and the last set (also r-x) shows the privileges for all other user on this system.

The three flags in each set are r, w, and x, in that order. They stand for read (r), write (w) and execute (x) and show the actions the users associated with each set can perform.

The file’s owner can read, write (or change), and execute (or run) this file. Users who are a member of the staff group, can read and execute this file, as well as every other user on this system.

Most documents and files merely contain text and data. The execute bit is not required. When you create a new file, the executable flag is disabled by default:

> touch newfile.txt
> ls -l newfile.txt
-rw-r--r--  1 armin  staff  newfile.txt

Having the execute bit disabled by default is a sensible security measure. You will have to explicitly enable it when building scripts or other executables.

When you are writing shell scripts, you eventually want to execute them. To do this you have to enable the execute bit. When then execute bit is unset, you cannot run the script from the command line.

To demonstrate this—and to gain some familiarity with the command line tools required to manipulate the file mode—disable the executable bit of our hello.sh script:

> chmod -x hello.sh

You can verify that the executable bit has been removed with the ls -l command:

> ls -l hello.sh
-rw-r--r--@ 1 armin  staff  45 Feb 17 14:36 hello.sh

When you now attempt to run the command, it will fail and the shell will tell you the reason: you do not have the right permissions. Without the executable script, your shell script is merely a text file.

> ./hello.sh
zsh: permission denied: ./hello.sh

You can re-set the executable bit with

> chmod +x hello.sh

You do not need write (w) permissions to execute a script, but you require read (r) permissions.

You can remove the executable privilege for group and/or other users for security. Since most Macs are used as single user systems this is not really relevant here. When you create, install, and run shell scripts on shared servers with other Unix or unix-like servers with many users, the proper access privileges may be extremely important. Please consult with the system administrator.

You will usually have to remember to set the executable bit when you create a new script. Some text editors may set it for you when they recognize you are working on a shell script.

Note: The executable bit has a different meaning for directories than it does for files. You can find the details in the chmod man page or in my book ‘macOS Terminal and Shell

Next: The Code

Scripting macOS, part 1: First Script

This series is an excerpt from the first chapter of my upcoming book “Scripting macOS” which will teach you to use and create shell scripts on macOS.

I will publish one part every week over the summer. Enjoy!

First Script

When learning programming languages it is tradition that the first program you write displays ‘Hello, World!’

Printing these words to the screen is usually a really simple task. In most programming languages it requires just a single line. Nevertheless, creating a simple program or script like this will teach you a lot about all the other tasks you need to create and run a script.

Before you start, you should create a folder where you store all the scripts you will create. A Documents subfolder is a good location. I have a Projects folder in my home directory where I create subfolders for all my scripting and programming projects. You may already have some structure like this that makes sense for you.

You can create this in Finder and the navigate there in Terminal. But, just to practice working in Terminal, you can create your script project folder in the command line:

> cd ~/Documents
> mkdir ScriptingMacOS
> cd ScriptingMacOS

Where- and however you create this folder, we will refer to it as your script project folder from now on. When you interact with the scripts from the command line, always remember to change the working directory there.

Note: When you know how to use a version control system to manage code, such as git, you can set up or initialize this project folder as repository now. Shell scripts are well suited to be managed with version control systems and I would recommend to use it when building scripts. But explaining the details of git as well as teaching shell scripting would be overwhelming and beyond the scope of this series.

Create your first script

Create a new text file in your favored text editor.

Note: If you do not have a favorite text editor yet, I recommend BBEdit. You can use it for free or pay the license fee to unlock the full feature set. After installing BBEdit, be sure to run ‘Install Command Line Tools…’ from the BBEdit menu.

I will be using the bbedit command line tools in my examples, but other text editors have similar commands.

When you are using a text editor with a command line tool, you can create a new empty file from the command line and open it in the text editor like this:

> bbedit hello.sh

Enter the following text into the text document:

#!/bin/zsh

# Greetings
echo "Hello, World!"

Tip: You can copy and paste the code from this post into the text editor. In general, I approve of this, since it speeds up the process and avoids typing errors. But in the beginning, I would recommend typing out the commands and modifying the script manually. This will engage your brain and memory differently and help you memorize some of the standard steps and commands.

Save the text file you created to a file named hello.sh in the script project folder.

In Terminal, ensure that the working directory is your script project folder. Then enter the following:

> chmod +x hello.sh

This will make your script file executable. You are telling the system this file has code that can be run as a program rather than just data. We will look at what this means in detail later.

Now you can run or execute your script:

> ./hello.sh
Hello, World!

If you get a different result, please verify that you typed the script exactly as given above. Pay extra attention to spaces, quotes and other special characters. Then save the text file and try running the script again:

> ./hello.sh
Hello, World!

Even for a script as simple as this, there is a lot that happened here. We will look at the pieces and steps in detail in the next part.

Next: The Script File

Installomator v0.6

We have posted an update for Installomator, which brings it to v0.6.

The changes are as follows:

  • several new and updated labels, for a total of 302
  • versionKey variable can be used to choose which Info.plist key to get the version from
  • an appCustomVersion() {} function can now be used in a label
  • with INSTALL=force, the script will not be using updateTool, but will reinstall instead
  • added quit and quit_kill options to NOTIFY
  • updated buildCaseStatement.sh
  • updated buildInstallomatorPkg.sh to use notarytool (requires Xcode 13)
  • several minor fixes

There have been some other organizational changes as well. We have moved the repo to its own team on GitHub: Installomator/Installomator. This should reflect that I am no longer the sole, or even the main contributor. Many thanks to Søren Theilgaard, Isaac Ordonez, and Adam Codega for helping maintain this!

And many thanks to everyone else who contributed!

Get Password from Keychain in Shell Scripts

MacAdmin scripts often require passwords, mostly for interactions with APIs.

It is easiest to store the password in clear text, but that is obviously a terrible solution from a security perspective. You can pass the password as an argument to your script, but that is inconvenient and may still appear in clear text in the ps output or the shell history.

You can obfuscate the password with base64, but that is easily reversible. You can even try to encrypt the password, but since the script needs to be able to decrypt the password, you are just adding a layer of complexity to the problem.

macOS has a keychain, where the user can store passwords and allow applications and processes to retrieve them. We can have our script retrieve a password from a local keychain.

There are limitations to this approach:

  • the password item has to be created in the keychain
  • the user has to approve access to the password at least once
  • the keychain has to be unlocked when item is created and when the script runs—this usually requires the user to be logged in
  • the user and other scripts can find and read the password in the Keychain Access application or with the security tool

Because of these limitations, this approach is not useful for scripts that run without any user interaction, e.g. from a management system. Since the user can go and inspect the key in the Keychain Access is also not well suited for critical passwords and keys.

However, it is quite useful for workflow scripts that you run interactively on your Mac. This approach has the added benefit, that you do not have to remember to remove or anonymize any keys or passwords when you upload a script to GitHub or a similar service.

Note: Mischa used this in his ‘OnAirScanner’ script.

Update: I didn’t remember this, but Graham Pugh has written about this before.

How to Store a Password in the Keychain

Since adding the password to your keychain is a one-time task, you can create the password manually.

Open the Keychain Access application and choose “New Password Item…” from the Menu. Then enter the Keychain Item Name, Account Name and the password into the fields. The “Keychain Item Name” is what we are going to use later to retrieve the password, so watch that you are typing everything correctly.

You can also add the password from the command line with the security command.

> security add-generic-password -s 'CLI Test'  -a 'armin' -w 'password123' 

This will create an item in the Keychain with the name CLI Test and the account name armin and the horribly poor password password123.

How to Retrieve the Password in the Script

To retrieve a password from the keychain in a script, use the security command.

> security find-generic-password -w -s 'CLI Test' -a 'armin'

This will search for an item in the keychain with a name of CLI Test and an account name of armin. When it finds an item that matches the name and account it will print the password.

The first time you run this command, the system will prompt to allow access to this password. Enter your keychain password and click the ‘Always Allow’ button to approve the access.

This will grant the /usr/bin/security binary access to this password. You can see this in the Keychain Access application in the ‘Access Control’ tab for the item.

When you create the item with the security add-generic-password binary, you can add the -T /usr/bin/security option to immediately grant the security binary access.

Whether you grant access through the UI or with the command, keep in mind that a every other script that uses the security binary will also gain access to this password.

For very sensitive passwords, you can just click ‘Allow’ rather than ‘Always Allow.’ Then the script will prompt interactively for access every time. This is more secure, but also requires more user interaction.

Once you have tested that you can retrieve the password in the interactive shell, and you have granted access to the security binary, you can use command substitution in the script to get the password:

cli_password=$(security find-generic-password -w -s 'CLI Test' -a 'armin')

This command might fail for different reasons. The keychain could be locked, or the password cannot be found. (Because it was either changed, deleted or hasn’t been created yet.) You want to catch that error and exit the script when that happens:

pw_name="CLI Test"
pw_account="armin"

if ! cli_password=$(security find-generic-password -w -s "$pw_name" -a "$pw_account"); then
  echo "could not get password, error $?"
  exit 1
fi

echo "the password is $cli_password"

Virtual JNUC 2020 Session: Scripting Jamf Pro – Best Practices

You can find my notes to my Virtual JNUC 2020 session here.

The session should be available “on demand” in the JNUC2020 portal within an hour or so. I believe all sessions will be available on YouTube eventually and will update the links then!

I hope you enjoyed the session, and if you have any more questions or comments, then I am @scriptingosx on the MacAdmins Slack and Twitter.

Avoiding AppleScript Security and Privacy Requests

AppleScript on macOS is a useful tool for pro users and administrators alike. Even though it probably is not (and shouldn’t be) the first tool of choice for many tasks, there are some tasks that AppleScript makes very simple. Because of this it should be a part of your ‘MacAdmin Toolbelt.’

AppleScript’s strength lies in inter-application communication. With AppleEvents (or AppleScript commands) you can often retrieve valuable information from other applications that would be difficult or even impossible, to get any other way. With AppleScript, you may even be able to create and change data in the target applications.

If you are in any way security and privacy minded this should raise your hairs. Up to macOS 10.13 High Sierra, any non-sandboxed app could use AppleScript and AppleEvents to gather all kinds of personal and private data from various script-enabled apps and services. It could even use script-enabled apps like Mail to create and send email in your name.

Since macOS Mojave, the Security and Privacy controls restricts sending and receiving AppleEvents. A given process can only send events to a different process with user approval. Users can manage the inter-application approvals in the Privacy tab of the Security & Privacy preference pane.

MacAdmins have the option of pre-approving inter-application events with a PPPC (Privacy Preferences Policy Control) configuration profile that is pushed from a DEP-enrolled or user-approved MDM.

Privacy approval

You can trigger the security approval from Terminal when you send an event from the shell to another process with osascript:

> osascript -e 'tell application "Finder" to get POSIX path of ((target of Finder window 1) as alias)'

When you run this command from Terminal, you will likely get this prompt:

You will not get this prompt when you have approved or rejected the Terminal app to send events to this particular target application before. You can check the permissions granted by the user in the Automation section of Privacy tab in the Security & Privacy pane of System Preferences.

For any given source/target application combination, the prompt will only be shown once. When the user approves the privilege (“OK” button), future events will just be allowed.

When the user rejects the connection (“Don’t Allow” button), this event and future events will be rejected without further prompts. The osascript will fail and the AppleScript will return an error –1743.

> osascript -e 'tell application "Finder" to get POSIX path of ((target of Finder window 1) as alias)'
79:84: execution error: Not authorized to send Apple events to Finder. (-1743)

If you want to get the approval dialogs again, you can reset the state of the source application (Terminal) with the tccutil command:

> tccutil reset AppleEvents com.apple.Terminal

This will remove the Terminal application and all target applications for it from the Automation (AppleEvents) area in the Privacy pane and show dialogs for every new request going forward. This can be very useful during testing.

Dealing with rejection

You should write your code in a ways that it fails gracefully when access is not granted. in this case osascript will return an error:

if ! osascript -e ' tell app "Finder" to return POSIX path of ((target of Finder window 1) as alias)'
then
 echo "osascript encountered an error"
 exit 1
fi

However, osascript will return errors for all kind of failures with no easy way to distinguish between them. As an example, the above will also fail when there are no Finder windows open.

If you want to distinguish AppleScript errors, you need to do so in the the AppleScript code:

if ! osascript -s o <<EndOfScript
    tell application "Finder"
        try
            set c to (count of Finder windows)
        on error message number -1743
            error "Privacy settings prevent access to Finder"
        end try
        
        if c is 0 then
            return POSIX path of (desktop as alias)
        else
            return POSIX path of ((target of Finder window 1) as alias)
        end if
    end tell
EndOfScript
then
    echo "osascript failed"
fi

Note: the -s o option of osascript makes it print AppleScript errors to standard out rather than standard error, which can be useful to find the errors in logs of management systems.

Note 2: when you are running osascript from management and installation scripts (which run as the root user) you need to run them as the current user to avoid problems.

Avoiding Privacy prompts

So, we know of one way to deal with the privacy prompts. Ideally, you would want to avoid them entirely. While this is not always possible, there are a few strategies that can work.

Don’t send to other Processes

In past versions of Mac OS X (I use this name intentionally, it’s that long ago.), scripts that showed dialogs might not display on the highest window layer. In other words, the dialog was lost behind the currently active windows. To avoid “lost” dialogs, it became best practice to send the display dialog command (and similar) to a process that had just received an activate command as well:

tell application "Finder"
    activate
    display dialog "Hello, World!"
end tell

As an alternative for Finder, the System Events process is often used as well. Jamf MacAdmins often used “Self Service.” This had the added bonus, that the dialog looks as if it comes from the Finder or Self Service, including the bouncing dock icon.

Over time, even though the underlying problem with hidden dialog has been fixed, this practice has persisted. You often even see AppleScript code use this with commands other than user interaction, where it wouldn’t have made sense in the first place. With the privacy restrictions in macOS Mojave, this practice has become actively trouble some, as you are sending the display dialog (or other) command to a separate process. The process running this script will require approval to send events to “System Events.”

osascript <<EndOfScript
    tell application "System Events"
        activate
        display dialog "Hello, World!"
    end tell
EndOfScript

In current versions of macOS, you can just use display dialog and may other commands without an enclosing tell block. Since your AppleScript code isn’t sending events to another process, no privacy approval is provided. This code has the same effect as above, but does not trigger an approval request.

osascript <<EndOfScript
    display dialog "Hello, World!"
EndOfScript

To determine whether an AppleScript command requires a tell block, you have to check where it is coming from. Many AppleScript commands that are useful to MacAdmins are contained in the ‘StandardAdditions’ scripting extension. Scripting extensions, as the name implies, extend the functionality of AppleScript without requiring their own process.

The useful commands in the Standard Additions extension include:

  • user interaction: choose file/folder/from list, display dialog/alert/notification
  • file commands: mount volume
  • clipboard commands: get the clipboard, set the clipboard to
  • sound control: set volume, get volume settings
  • system info

When your script uses only these commands, make sure they are not contained in tell blocks. This will avoid unnecessary prompts for access approval.

Exempt AppleScript commands

Some AppleScript commands are treated differently and will not trigger privacy approval:

  • activate: launch application and/or bring to front
  • open: open a file
  • open location: open a URL
  • quit: quit the application

For example, this will work without requiring approval:

osascript <<EndOfScript
    tell application "Firefox"
        open location "https://scriptingosx.com"
    end
EndOfScript

Use non-AppleScript alternatives

Sometimes, similar effects to an AppleScript can be achieved through other means. This can be difficult to figure out and implement.

As an example, I used this AppleScript command frequently for setup before Mojave:

tell application "Finder" to set desktop picture to POSIX file "/Library/Desktop Pictures/BoringBlueDesktop.png"

While Mojave was in the beta and it wasn’t really clear if or how the PPPC exemptions could be managed, I looked for a different means. I discovered Cocoa functions to read and change the desktop picture without triggering PPPC, and built a small command line tool out of that: desktoppr.

The downside of this approach is that you know have to install and/or manage a command line tool on the clients where you want to use it. There are different strategies for this, but it is extra effort compared to “just” running an AppleScript.

Build PPPC profiles to pre-approve AppleEvents

Even after you have considered the above options to avoid sending AppleEvents to another process, there will still be several situations where it is necessary. For situations where a MacAdmin needs to run a script on several dozens, hundreds, or even thousands of Macs, user-approval is simply not a feasible option.

MacAdmins can pre-approve AppleEvents (and most other privacy areas) between certain processes with a Privacy Preferences Policy Control (PPPC) configuration profile. PPPC profiles can only be managed when pushed from a user-approved or automatically enrolled MDM.

You can build such a profile manually, but it is much easier to use a tool to build these:

Your MDM solution might have a specific tool or web interface for this, consult the documentation or ask you vendor.

There is one big requirement here, though: only applications and tools that are signed with a valid Apple Developer ID can be pre-approved this way, as the signature is used to identify and verify the binary.

Determining the process that needs approval

While you can sign shell scripts and other scripts this is often not necessary. As we have seen earlier, when we ran our script from Terminal, it wasn’t the script that requested approval but the Terminal application. When your scripts run from a management system or another tool, it may not be easy to determine which process exactly needs approval.

The most practical approach to determine this, is to log the output of the ’Transparency, Consent, and Control” system (tcc) and look which process is sending the requests.

First, either use a clean test system, or reset the approvals for the processes that you suspect may be involved with tccutil.

Then open a separate Terminal window and run this command which will show a stream of log entries from the tcc process:

> log stream --debug --predicate 'subsystem == "com.apple.TCC" AND eventMessage BEGINSWITH "AttributionChain"'

There will be a lot of noise in this output.

Then run the script in question, the way you are planning to run it during deployment. If you are planning to run the script from a management system, then do that right now. You will get a lot output in the stream above.

Even when you don’t have a good idea what the parent process is going to be, you can filter the output for osascript since this is usually the intermediary tool used.

In my example I found several entries similar to this:

   0    tccd: [com.apple.TCC:access] AttributionChain: RESP:{ID: com.barebones.bbedit, PID[1179], auid: 501, euid: 501, responsible path: '/Applications/BBEdit.app/Contents/MacOS/BBEdit', binary path: '/Applications/BBEdit.app/Contents/MacOS/BBEdit'}, ACC:{ID: com.apple.osascript, PID[18756], auid: 501, euid: 501, binary path: '/usr/bin/osascript'}, REQ:{ID: com.apple.appleeventsd, PID[577], auid: 55, euid: 55, binary path: '/System/Library/CoreServices/appleeventsd'}

The important information here is the responsible path which give me the binary and the enclosing application that tcc considers ‘responsible.’ This is the application you need to approve.

When you are running your scripts from a management system, your MDM vendor/provider should already have documentation for this, to save you all this hassle.

With all this information, you can build the PPPC profile with one of the above tools, upload it to your MDM and push it to the clients before the deployment scripts run.

Conclusion

While the added privacy around AppleEvents is welcome, it does add several hurdles to automated administration workflows.

There are some strategies you can use to avoid AppleScripts triggering the privacy controls. When these are not sufficient, you have to build a PPPC profile to pre-approve the parent process.

macOS Version Big Sur Update

Versioning software is a surprisingly difficult task. The version number conveys technical information: how much change can you expect and might it break existing workflows and data? Users will be more willing to pay for a major upgrade with new features, so the version number is used as a marketing tool. But for developers and MacAdmins, the version number has to be as granular as possible, ideally with a different number for each build made.

Because of these, sometimes opposing, interests, it is no wonder that versioning is often a problem for MacAdmins.

A brief history of the Mac operating system versions

Before the “Mac OS” label, the Macintosh operating system was called “System 7.” “Mac OS 8”, code-named “Copland,” was supposed to be the new operating system with all the new features, but the project kept slipping and then was cancelled. The “System 7.7” update was then re-named “Mac OS 8” to get Apple out of some third party licensing deals, which were set to expire on version 8. Marketing and legal matters decided the versioning here. (But it wasn’t all just marketing: Mac OS 8 also got the new interface design that had been created for Copland.)

When Apple announced the NextSTEP based major overhaul for the Macintosh in the late nineties, they chose to not give it a different name and new version numbers. Instead they chose to label the new system as “Mac OS X”, where the “X” was read as the roman numeral ten. I assume this was a marketing choice to demonstrate continuity with the previous versions, which had been “Mac OS 8” and “Mac OS 9.”

The first version of Mac OS X had the numerical version number 10.0.0 and got four “updates” to 10.0.4 Then it got the first major “upgrade” to 10.1. This broke with conventional versioning as major upgrades should get a new major number. When “Mac OS X 10.2 Jaguar” was announced at WWDC in 2002, it was obvious that Apple now considered “Mac OS X” a brand and would stick to the 10 in the system version numbers.

With the “Xserve,” the ‘X’ became a moniker to represent Apple’s “professional” or “EXpert” hardware and distinguish them from the ‘i’ prefix used for the consumer friendly devices and software. (iMac, iBook, iPod, iTunes, iMovie, iPhoto, etc.) Later “pro” software tools, such as “Xcode,” “Xsan,” and “Xgrid” picked up that prefix. Confusingly, the leading “X” was pronounced ‘ecks’ and the trailing ‘X’ in “Mac OS X” was still pronounced as ‘ten.’ Or at least that was what Apple marketing insisted it should be called.

In 2012, Apple dropped the “Mac” from the operating system for “OS X Mountain Lion” (10.8) The “X” represented the “pro” nature of Mac platform, as opposed to “iOS” for iPhone and iPad. Apparently, the “X” was considered a stronger brand for “pro” than “Mac” at the time…

This changed when Apple finally dropped the “X-as-ten” and returned the “Mac” with “macOS Sierra” (10.12) in 2016.

Even without the “X” in the system name, the ‘10’ has remained in the version number up to macOS 10.15 Catalina.

(The “X-as-ten” lives on with “Final Cut Pro X” and “Logic Pro X”. The prefix “X” lives on with “Xcode.”)

Big Sur goes to 11

The naming and versioning of the Mac platforms operating system was largely driven by symbolism, marketing and even legal matters. The same is true this year: Apple has finally given up on the “ten” and will raise the major version of macOS to 11.

I have a post on my opinion and reaction to macOS 11. Whatever the reasons, this is the year that we will get macOS 11.

When you go to “About this Mac” on a Mac running the Big Sur beta, it will proudly announce that it is running macOS 11.0. You get the same result when you run sw_vers:

% sw_vers -productVersion
11.0

This seems easy enough, however, there is a catch to this.

10.16 vs 11.0

Big Sur will not always report 11.0 as the macOS version. It might report 10.16 in some cases. These cases are meant for situations where software might just check the second part of the version for version checks.

The rules are detailed in this post from Howard Oakley.

In Big Sur you can force the compatibility mode by setting the SYSTEM_VERSION_COMPAT environment variable to 1:

export SYSTEM_VERSION_COMPAT=1
> sw_vers -productVersion
10.16

Checking the Version

When you have a script that checked the version of macOS (or Mac OS X or OS X), so far it was safe to ignore the first number of the product version and only compare the second. For example to see if macOS was Mojave or newer, you probably would have done something like this:

minorVersion=$(sw_vers -productVersion | awk -F. '{ print $2; }')
if [[ $minorVersion -ge 14 ]]; then
  echo "Mojave or higher"
fi

Now, with the release of macOS 11.0, this setup will return 0 for the minorVersion and fail.

The obvious solution here would be to extract the majorVersion as well and compare that first:

majorVersion=$(sw_vers -productVersion | awk -F. '{ print $1; }')
minorVersion=$(sw_vers -productVersion | awk -F. '{ print $2; }')
if [[ $majorVersion -ge 11 || $minorVersion -ge 14 ]]; then
  echo "Mojave or higher"
fi

This will work well enough, even when the script runs in a setup where it might get 10.16 as the version number. But is not particularly nice to read. Also, when you want to compare update versions, these will be (presumably) the minorVersion for Big Sur and later and the third part of the version number in Catalina and earlier and things will get even more messy quickly.

Maybe there is a better way of doing this than using the product (marketing) version of macOS?

Build Version

As I mentioned earlier, the user visible version may not be granular enough for the needs of developers. And because of this macOS has a second version, called the “build version”

The build version for the current version of macOS Catalina as I am writing this is 19G2021.

> sw_vers -buildVersion
19G2021

You can also see the build version in the “About this Mac” window when you click on the version number.

The build version consists of three parts and a fourth optional one. The first number is the Darwin Version. The following capital letter designates the update. The following number (up to four digits) is the specific build number. Sometimes the build number is followed by a lower case letter.

Darwin Version

This part of the version takes its name from the Darwin core of macOS.

The Darwin Version is number that is increased on every major release of macOS. Mac OS X 10.2 Jaguar was the first release of Mac OS X to consistently report its Darwin Version as 6. From that you can conclude that 10.0 had Darwin version 4 which makes sense, because it was the fourth release of NextSTEP, the operating system Mac OS X is based on.

macOS 10.15 Catalina, has a Darwin version of 19 and Big Sur reports 20.

You can also get the Darwin version in the shell from the OSTYPE environment variable:

> echo $OSTYPE
darwin19.0

But keep in mind that environment variable may not be set depending on the context your script runs in.

A safer way to get the Darwin version is with the uname command:

> uname -r
19.6.0

This Darwin version includes the update information.

Updates

In the build version updates are tracked with a capital letter. The letter A stands for the .0 or first release of a major version. B signifies the second release or first update or .1 release, and so on.

The current Catalina build version starts with 19G so we know it is the seventh release or sixth update to Catalina (10.15.6). The current Big Sur beta starts with 20A so it is the first release or .0 version.

Build numbers

The significance of the build number is most often seen during the beta phase. While the Darwin version and update letter are fixed during the beta phase, the build number increases with every beta release. This is the most granular number we have to determine the version.

For each update and major release the build number starts over, so it can only be used to compare releases for the same major release and update version.

Traditionally, there was a difference between two- and three-digit build numbers and four-digit build numbers. The builds with lower numbers of digits were general release builds, that will run on all Macs that support this version of macOS. The four digit build numbers designated either a security update or a hardware specific build.

Hardware specific builds occur when a new Mac model is released. These usually get a hardware specific build of macOS, since the drivers necessary for the new hardware are not included in the current general release version. Even though the product version numbers of macOS are the same for the general release and the hardware specific release, they have different build numbers.

Usually, the hardware specific drivers are merged into the general release on the next update. However, until the builds are merged, MacAdmins may have to manage hardware specific system installers and workflows for new hardware. This was especially annoying with the release of the 2018 MacBook Pro which had a specific build of 10.13.6 that was never merged into the general 10.13 build. MacAdmins that wanted or needed to support 10.13 had to manage a separate installer for these MacBooks.

Intruigingly, the Big Sur beta is different: its build number started in the 4000s and switched to the 5000s with beta 3.

Special builds

Some releases of macOS have a trailing lower case letter after the build number. This is particularly common during the beta phase. It is unclear what this letter designates exactly. It might designate that the installer application was re-built one or more times.

You can use regular expressions to parse out all the individual pieces of the build version:

if [[ $buildVersion =~ ([[:digit:]]{1,2})([[:upper:]])([[:digit:]]+)(.*) ]]; then
    darwinVersion=$match[1]
    updateLetter=$match[2]
    buildNumber=$match[3]
    specialBuild=$match[4]
else
    echo "couldn't parse build version"
    exit 2
fi

But in most cases, you will not need this level of detail.

Using the Build Version

The build version provides a way to compare macOS system versions that is not subject to the whims of marketing. We can even use it distinguish hardware specific builds of macOS from general versions or determine if security or supplemental updates have been applied.

For most comparisons, we only need the Darwin version and maybe the update.

The Darwin version has had two digits since Mac OS X 10.6 Snow Leopard. It is safe to assume that you won’t be managing Macs running 10.5 Leopard any more. (And if you do, they will probably be “hand-fed” and not subject to your deployment and update automations.) Assuming a two digit Darwin version, we can use string comparison to compare build versions:

# check if Mojave or higher
if [[ $(sw_vers -buildVersion) > "18" ]]; then
...

Since all versions of Mojave start with 18A... they are all alphabetically greater than 18. The same would go if you want to check for a maximum macOS version:

# check if Mojave or earlier
if [[ $(sw_vers -buildVersion) < "19" ]]; then
...

You can also filter for specific minimum updates:

# check if Mojave 10.14.6 or later
if [[ $(sw_vers -buildVersion) > "18E” ]]; then
...

By using the build version, we are avoiding all the trouble that the “marketing-driven” build version brings with it.

zsh solution

The above works for sh, bash and zsh scripts. However, when you are using zsh, there is another useful solution. zsh provides a function to compare versions called is-at-least.

When you use zsh in the Terminal interactively, it is probably already loaded, but when you want to use it in scripts, you should use autoload to make sure it is loaded. Then you can use is-at-least this way:

autoload is-at-least
# check for Mojave or higher
if is-at-least 10.14 $(sw_vers -productVersion); then
...

Since both 11.0 and 10.16 are higher than 10.14 this will work no matter what number Big Sur might be reporting, but if you want to check that the system is Big Sur, you want to use 10.16 as the minimum, which covers both possible values:

autoload is-at-least
# check for Big Sur or higher
if is-at-least 10.16 $(sw_vers -productVersion); then
...

Conclusion

The change of the version number in macOS 11 Big Sur might affect or even break some of your system version checking in your deployment and management scripts. There are some nice and easy solutions that are more resilient to changes in the “marketing” product version.