Mobile device apps in Jamf Pro drift. Someone adds an app in a hurry and forgets the category. Someone else scopes it to one group and never adds the standard one. A flag that should be on is off. Individually none of these matter; across a few hundred app records, you end up with an inventory nobody trusts.

I needed a way to find the drift, fix it in bulk, and be able to undo it if I got something wrong. The Jamf UI is fine for editing one app. It is not fine for editing two hundred.

So I wrote a Python CLI. Then I rewrote it as a SwiftUI app so other people on the team could use it without me.


The Baseline

Every selected app gets brought to the same set of settings:

  • Category set to iOS Apps
  • The All Managed iPhones group added to scope, with every existing target, exclusion, and limitation preserved
  • Display in Self Service after install: on
  • Make app managed when possible: on
  • Convert unmanaged app to managed: on
  • Assign Content Purchased in Volume: on
  • App update settings: untouched

The word “added” in the scope line is the important one. Some of these apps are also scoped to iPads or to specific groups. A baseline tool that replaces scope would be a footgun, so the tool only ever appends the target group.


The CLI Workflow

The Python tool (jamf_ios_app_baseline.py) has three subcommands and uses only the standard library: urllib for HTTP, xml.etree for the Classic API’s XML.

1. Audit. Walk every mobile app, compare it to the baseline, and write a CSV:

python3 jamf_ios_app_baseline.py audit --output jamf_mobile_app_changes.csv

The CSV has current and recommended values side by side, a Baseline Status column, a Differences column, and an empty Apply column. Put an x on the rows you want fixed.

2. Apply. Without --commit, this is a preview. It prints only the settings that would change for each selected app:

python3 jamf_ios_app_baseline.py apply jamf_mobile_app_changes.csv

3. Commit. Same command with the flag that makes it real:

python3 jamf_ios_app_baseline.py apply jamf_mobile_app_changes.csv --commit --stop-on-error

Preview is the default and --commit is opt-in. I would rather forget a flag and see a harmless dry run than forget a flag and change two hundred records.


Drift Detection Is Just a List of Strings

Compliance is computed in one function. Each failing check appends a short name to a list, and the status is derived from the list:

if current_category.casefold() != target_category.casefold():
    differences.append("category")
...
if make_managed is not True:
    differences.append("make_managed")
if convert_unmanaged is not True:
    differences.append("convert_unmanaged")
if volume is not True:
    differences.append("volume_content")

if not differences:
    baseline_status = "Compliant"
else:
    baseline_status = f"Needs Review ({len(differences)})"

The same function runs on the original record for the audit and on the in-memory modified record to show the expected result in the preview. “Would this change fix it?” is answered by running the check again on the result.


Minimal PUT Payloads and Safety Rails

The Classic API will accept a PUT with only the elements you want to change. Sending back the full GET representation is tempting, but it can include fields Jamf will not accept unchanged. So the updater builds a small document containing only what the tool owns: category, the three management flags, the scope, and the volume flag.

Around every write, the commit path does the same four things:

  1. Re-read the app from Jamf, so the backup reflects current state rather than what the audit saw an hour ago.
  2. Write the full XML to a timestamped backup directory.
  3. Send the minimal payload.
  4. GET the app again and run the compliance check. If it is not Compliant, raise an error.
backup_path = backup_dir / f"{app_id}.xml"
ET.ElementTree(root).write(backup_path, encoding="utf-8", xml_declaration=True)
payload = build_update_payload(root, category_id, category_name, group_id, group_name, vpp_account_id)
client.update_mobile_app(app_id, payload)
verified = extract_app(client.get_mobile_app(app_id), args.category, args.scope_group)
if verified["Baseline Status"] != "Compliant":
    raise JamfError(f"Verification failed after update: {verified['Differences']}")

Jamf has no transaction across records, so --stop-on-error halts at the first failure. Undo is a restore subcommand that takes a backup directory and replays the same minimal fields from each saved XML file, again without pushing the whole record back.


The Volume Purchasing Account Problem

The one genuinely annoying part was enabling “Assign Content Purchased in Volume.” Through the Classic API, that flag needs a valid Volume Purchasing account ID in the same payload, and the UI hides that detail. Many app records report -1 for the field, which is not usable.

The obvious fix is to list the Volume Purchasing accounts and pick one. I did not want to require read access to that endpoint on the API client for a tool that otherwise only touches mobile apps, so the tool infers the ID instead:

  1. Use vpp_account_id from the config if one is set.
  2. Otherwise look at the selected apps for an existing positive ID.
  3. Otherwise scan the remaining apps until one turns up.
  4. If the apps disagree, or none has one, stop before changing anything and ask for the numeric ID once.
found = {v for root in selected_roots.values() if (v := positive_vpp_id(root)) is not None}
if len(found) == 1:
    return next(iter(found)), "inferred from selected apps"
if len(found) > 1:
    raise JamfError("Selected apps reference multiple VPP account IDs ... Set vpp_account_id ...")

This only works because the tenant has a single Volume Purchasing location, and the code says so by refusing to guess when the IDs conflict. The numeric ID is not a secret, so it is fine in a config file.


Credentials: 1Password, No Secrets in Files

The Jamf URL, client ID, and client secret live in a 1Password item. The CLI reads each with op read using a secret reference, and the non-secret bits (vault name, item name, category, group) sit in a small JSON file next to the script. Resolution order is CLI flag, environment variable, explicit reference, then the item. Names with spaces are percent-encoded before they go into the op:// reference, so there is no shell interpolation involved. This is the same approach I took in the TestFlight tool, minus the temp-file handling since there is no key file to write.


Why Rewrite It as an App

The CLI worked. The problem was the audience. Editing a 200-row CSV and running the right two commands in the right order is fine for me and a hard sell for anyone else.

The SwiftUI version keeps the model and drops the ceremony:

  • A table of every mobile app with category, scope, the four flags, and a status column.
  • A checkbox per row, plus Select Needs Review, which replaces the x column in the CSV.
  • A Preview Changes sheet listing the exact intended changes per app.
  • An Apply button that stays disabled until you tick “I reviewed these changes.”
  • A results sheet with a success or failure line per app. Apply stops on the first error.

Under the hood it is a port, not a redesign. The service layer does the same OAuth client-credentials flow, the same Classic API calls, the same minimal payload, and the same backup-before-write, with backups under ~/Library/Application Support/Jamf App Baseline Manager/Backups. The README describes the PUT payload as intentionally matching the proven Python workflow, and that is the point: I did not want a second, subtly different implementation of the dangerous part.

Credentials in the App

The app reads from the same 1Password item. Since the Mac app cannot link a CLI library, it shells out to op read as a child process, with the three reads running concurrently:

func credentials(settings: BaselineSettings) async throws -> JamfCredentials {
    async let url = read(reference: secretReference(vault: settings.opVault, item: settings.opItem, field: settings.opURLField))
    async let clientID = read(reference: secretReference(vault: settings.opVault, item: settings.opItem, field: settings.opClientIDField))
    async let clientSecret = read(reference: secretReference(vault: settings.opVault, item: settings.opItem, field: settings.opClientSecretField))
    return try await JamfCredentials(url: url, clientID: clientID, clientSecret: clientSecret)
}

Only non-secret settings are persisted, as JSON in UserDefaults: vault, item, field names, category, group, and an optional Volume Purchasing account ID. The Jamf session uses an ephemeral URLSession, and credentials are fetched from 1Password at the start of each operation rather than cached by the app.

Launching op as a child process has a cost: the app has to run with App Sandbox disabled. Hardened Runtime stays on, and the README notes that distribution should be Developer ID signing plus notarization rather than the Mac App Store.


What I’d Do Differently

Two implementations of one payload. The Python and Swift versions both build the minimal PUT, so a baseline change means editing two places. It is the right trade for now, but a new baseline option should probably go into whichever one I use more and be ported deliberately.

The sandbox exception. Shelling out to op is the reason for the disabled sandbox. A native approach to fetching the secret would let the app be sandboxed, which is a better story for a tool that holds API credentials in memory.

Per-app writes. There is no all-or-nothing option, which is a Jamf limitation. Preview, backup, verify, and stop-on-error are mitigations, not a substitute. The first production runs should be a handful of apps, not the whole list.

The baseline is hard-coded in shape. Category and group are configurable; the four flags are not. The app is structured so each baseline option could become its own toggle, but today they are fixed.