Skip to content

Protect Workflows

Neil Martin edited this page Sep 11, 2026 · 6 revisions

Protect Workflows

Copy-paste recipes for Jamf Protect administration. For command details, see Jamf Protect Commands. For shared patterns (apply, scaffold, export), see CLI Patterns.

Instance Overview

# Full instance dashboard: endpoint counts, security config, alerts, compliance, roles
jamf-cli protect overview

# Machine-readable overview for alerting pipelines
jamf-cli protect overview -o json

Import Community Analytics and Build a Detection Set

The jamf/jamfprotect repository provides community-maintained analytics in YAML format. This walkthrough imports the OpenClaw agentic AI detections, creates an analytic set for them, and wires them together.

Import the analytics

# Clone the community repository
git clone --depth 1 https://github.com/jamf/jamfprotect.git

# Import the OpenClaw agentic detections
jamf-cli protect analytics import \
  --dir jamfprotect/custom_analytic_detections/agentic_detections/

Output:

Created analytic "OpenClawDirectoryCreated"
Created analytic "OpenClawGatewayPersistence"
Created analytic "OpenClawInstallation"
Created analytic "OpenClawOnboard"
Created analytic "OpenClawSkillsInstallClawhub"

Note: The import command is idempotent: it updates an existing analytic matched by name and creates the rest. Re-running the same import is safe.

Create an analytic set with the imported analytics

Analytic sets reference analytics by name, so you can create the set in one step:

# Create a set referencing the OpenClaw analytics by name
echo '{
  "name": "OpenClaw Detections",
  "description": "Community analytics for detecting OpenClaw agentic AI activity",
  "types": ["Report"],
  "analytics": [
    "OpenClawDirectoryCreated",
    "OpenClawGatewayPersistence",
    "OpenClawInstallation",
    "OpenClawOnboard",
    "OpenClawSkillsInstallClawhub"
  ]
}' | jamf-cli protect analytic-sets apply

Or build the set one analytic at a time:

# Create an empty set first
echo '{"name":"OpenClaw Detections","description":"OpenClaw agentic AI detections","types":["Report"],"analytics":[]}' \
  | jamf-cli protect analytic-sets apply

# Add analytics one at a time
jamf-cli protect analytic-sets add-analytic "OpenClaw Detections" --analytic OpenClawDirectoryCreated
jamf-cli protect analytic-sets add-analytic "OpenClaw Detections" --analytic OpenClawGatewayPersistence
jamf-cli protect analytic-sets add-analytic "OpenClaw Detections" --analytic OpenClawInstallation

Note: Adding an analytic that already exists in the set is a no-op, so it is safe to run again.

Add the set to a plan

Plans reference all components by name, so you can export, edit, and re-apply:

# Export the current plan: every reference is a readable name
jamf-cli protect plans export "Production Plan" > plan.json

# Add the OpenClaw set using jq
jq '.analyticSets += [{"name": "OpenClaw Detections", "type": "Report"}]' plan.json \
  | jamf-cli protect plans apply --yes

Import other community analytics

# Import all custom analytics
jamf-cli protect analytics import \
  --dir jamfprotect/custom_analytic_detections/

# Import smartcard-related detections only
jamf-cli protect analytics import \
  --dir jamfprotect/custom_analytic_detections/smartcard_config_activity/

# Import a single analytic
jamf-cli protect analytics import \
  --file jamfprotect/custom_analytic_detections/webapp_created.yaml

Export/Import Round-Trip

Back up a configuration, migrate between tenants, or version-control your Protect settings. Exported files carry every reference as a name, which makes them portable across tenants and readable in review.

# Export a plan to JSON, with every reference a name
jamf-cli protect plans export "Production Plan" > plan.json

# Export to YAML
jamf-cli protect plans export "Production Plan" -o yaml > plan.yaml

# Import into another tenant (names are resolved to IDs automatically)
jamf-cli protect plans apply --from-file plan.json --yes -p protect-staging

# Round-trip an analytic set, with analytics referenced by name
jamf-cli protect analytic-sets export "Threat Detection" \
  | jamf-cli protect analytic-sets apply --yes -p protect-staging

# Round-trip a unified logging filter set (v1.27.0+), filters referenced by name
jamf-cli protect ulfs export "Auth Logging" \
  | jamf-cli protect ulfs apply --yes -p protect-staging

# Export all analytics as individual YAML files
mkdir -p analytics
jamf-cli protect analytics list -o json | jq -r '.[].name' | while read name; do
  jamf-cli protect analytics export "$name" -o yaml > "analytics/${name}.yaml"
done

# Import them all into another tenant
jamf-cli protect analytics import --dir analytics/ -p protect-staging

Note: Plans, analytic sets, and all other resources can be exported from one tenant and applied to another as long as the referenced resources (action configs, exception sets, analytics, etc.) exist by the same name in the target tenant.

To capture or replay every resource in one command, use protect backup / protect restore; see Nightly Tenant Backup into Git and Clone a Tenant: Dev to Prod Promotion below. They write the same portable, name-referenced documents these export commands produce, and restore resolves the references in dependency order, which covers the "must already exist by name" caveat above.

Nightly Tenant Backup into Git

protect backup writes one file per object in the same portable form export produces, so a backup directory produces a readable diff and belongs in version control. This is the whole-tenant equivalent of the per-resource round-trip above.

#!/usr/bin/env bash
set -euo pipefail

REPO=/srv/protect-config
export JAMF_CLI_ARGS='--quiet --no-input'   # note: -o json breaks this step, see below

cd "$REPO"
git pull --ff-only

# Capture the tenant. Stale documents from earlier runs are pruned and reported,
# so the tree always describes the tenant as it is now.
jamf-cli protect backup --output ./tenant --format yaml -p protect-prod

git add -A tenant
git diff --cached --quiet && { echo "no configuration drift"; exit 0; }
git commit -m "protect: nightly capture $(date -u +%Y-%m-%dT%H:%M:%SZ)"
git push

Three things to get right before this runs unattended.

Do not put -o json in JAMF_CLI_ARGS for this step. --output on protect backup names a destination directory, so that local flag shadows the inherited one, the -o shorthand is gone, and the run exits 2. Use --format json for JSON files. (protect restore takes --input and keeps -o.) See CI/CD & Scripting for the order the CLI applies JAMF_CLI_ARGS in.

Keep the two secret-bearing documents out of the repository. Backup writes action-configs and data-forwarding 0600 and reports them at the end of the run, because it captures an HTTP report client's request headers verbatim, a bearer token or API key among them, and data forwarding's cloudformation blob embeds a tenant-specific IAM ExternalId. Git records no non-execute permissions, so a clone hands every file back 0644. Either exclude them from the capture, ignore them, or encrypt them:

# Option 1: never capture them
jamf-cli protect backup --output ./tenant \
  --exclude action-configs,data-forwarding

# Option 2: capture them and keep them out of the commit
cat >> .gitignore <<'EOF'
tenant/action-configs/
tenant/data-forwarding.yaml
tenant/data-forwarding.json
EOF

# Option 3: commit them encrypted (git-crypt, one-time setup)
git-crypt init
cat >> .gitattributes <<'EOF'
tenant/action-configs/** filter=git-crypt diff=git-crypt
tenant/data-forwarding.* filter=git-crypt diff=git-crypt
EOF

Option 1 is the safest default, though a backup taken with --exclude action-configs cannot restore the action configs a plan references by name: the restore reports them missing in the target. See Secrets & Keychain.

Decide what a partial failure should mean to the scheduler. Backup records a resource that fails to export in _failures.yaml and carries on, but the command exits non-zero so the job can tell an incomplete capture from a good one. Add --allow-partial-failure where the commit matters more than the alert:

jamf-cli protect backup --output ./tenant --allow-partial-failure \
  || echo "backup failed outright" >&2

Exit codes are in Error Handling & Exit Codes.

Verify the tree before you trust it

# Preview the prune this run would make
jamf-cli protect backup --output ./tenant -n

# Inventory the tree
find ./tenant -name '*.yaml' | wc -l
cat ./tenant/_meta.yaml          # which tenants have written here, and the last run's counts
cat ./tenant/_failures.yaml      # present only if something failed

-n, --dry-run on protect backup covers the pruning alone. Backup writes the documents --output asked for, deletes nothing, and reports every removal it would have made as [dry-run].

Never point protect backup at another tenant's backup directory. Pruning is keyed on the object set of the tenant being captured right now, so it would delete the other tenant's documents for every object this one lacks. The run refuses the prune when _meta records a different tenant; --no-prune writes alongside them and records this tenant too. An unparseable _meta beside existing documents refuses the run outright. Keep one directory per tenant.

Disaster Recovery: Restore a Tenant from a Backup

# 1. Preview. Nothing is sent to the API.
jamf-cli protect restore --input ./tenant -p protect-prod -n

# 2. Restore everything, in dependency order
jamf-cli protect restore --input ./tenant -p protect-prod --yes

# 3. Confirm
jamf-cli protect overview -p protect-prod

Restore updates what exists, creates what is missing, and deletes nothing. An object in the tenant that the backup does not carry survives untouched, so a restore converges the objects the backup describes and no others. It is safe against a live tenant, and it cannot undo a creation.

Per-object granularity is the filesystem. There is no --only "Production Plan" flag; delete the files you do not want applied:

# Restore plans and their dependencies, but hold one plan back
cp -R ./tenant ./tenant-partial
ls ./tenant-partial/plans/          # find the file: "Test Plan" is written Test_Plan.yaml
rm ./tenant-partial/plans/Test_Plan.yaml
jamf-cli protect restore --input ./tenant-partial -p protect-prod --yes

# Or narrow by resource
jamf-cli protect restore --input ./tenant \
  --resources analytics,analytic-sets,plans -p protect-prod --yes

# Or restore everything except the identity objects
jamf-cli protect restore --input ./tenant \
  --exclude users,groups,roles -p protect-prod --yes

Object names are free text and file names are not, so a name's non-word characters collapse to _ ("Test Plan" becomes Test_Plan.yaml), and two objects whose names sanitise to the same string get a name-derived discriminator appended so neither overwrites the other. List the directory to get the file name.

Backup captures two resources without replaying them, and reports each one it skips: api-clients (the server issues a new secret on create, so a replayed client is a different client) and data-forwarding (its settings carry credentials the API never returns). It captures insights and connections as state documents for reference, the insight catalogue being Jamf-published and identity provider connections having no create API. Restore skips the tenant defaults (the built-in roles, the Default group, the Default Analytic Set) unless you pass --include-defaults.

A mixed result exits 7 (PartialFailure), so a job observes both halves of a round trip the way it observes a backup. --allow-partial-failure downgrades it.

Restore sends an empty membership list where plans apply leaves one alone. plans apply reads an omitted or empty reference list as "leave existing membership unchanged"; restore converges the target on the document, so an analytic set someone attached to a plan after the backup is removed on a rollback. usbControlSet is the exception, having no explicit-null mechanism in the API: a removable storage control set detached after the backup stays attached on the tenant.

Clone a Tenant: Dev to Prod Promotion

Every cross-resource reference in a backup is a name, so a backup taken from one tenant applies to another unchanged. This is the whole-tenant version of the export/import round-trip.

# 1. Capture the source tenant
jamf-cli protect backup --output ./from-dev -p protect-dev

# 2. Review what is about to land: ordinary YAML, one file per object
find ./from-dev -name '*.yaml' | sort

# 3. Hold back what should not cross the boundary. (api-clients and
#    data-forwarding are never replayed, so they need no action.)
rm -rf ./from-dev/users

# 4. Preview against the target
jamf-cli protect restore --input ./from-dev -p protect-prod -n

# 5. Apply
jamf-cli protect restore --input ./from-dev -p protect-prod --yes

# 6. Re-run to confirm idempotency: the second run should report the same
#    objects applied and nothing failed
jamf-cli protect restore --input ./from-dev -p protect-prod --yes

To confirm the clone landed, capture the target and diff the two trees:

jamf-cli protect backup --output ./from-prod -p protect-prod
diff -r ./from-dev ./from-prod

_meta differs by construction, recording the tenant that wrote the directory and when, and so does anything you deleted from the source tree in step 3.

Two fields differ after a clone, and both are correct:

Field Reason
A plan's commsConfig.fqdn The region-assigned IoT endpoint. The target keeps its own.
An exception's analyticUuid Rebound by name against the target. Custom analytics get per-tenant UUIDs, so equality here would mean the rebinding failed.

Everything else matches.

An exception set's target analytic is exported by name as well as UUID, and the server rejects a foreign analyticUuid: it answers createExceptionSet: Action blocked due to dependencies on this resource., naming neither the analytic nor the uuid nor the cause. Restore therefore applies exception sets after the analytics they reference. Do not hand-edit a UUID in an exported document.

Port Analytic Severity Overrides Between Tenants

The server refuses analytics apply against a Jamf-managed analytic. A tenant can change an overlay: the severity the analytic is reported at, and the actions it triggers. That overlay is the tenant's own data, and it does not travel with analytics export, which emits the community YAML definition and drops the overlay with no warning.

# List this tenant's customisations
jamf-cli protect analytics overrides list -p protect-dev

# Port the whole overlay to another tenant
jamf-cli protect analytics overrides export -p protect-dev \
  | jamf-cli protect analytics overrides apply --yes -p protect-prod

# Or keep it in version control alongside the rest of the config
jamf-cli protect analytics overrides export -p protect-prod > overrides.json
git add overrides.json && git commit -m "protect: analytic overlay"

# Replay from the file
jamf-cli protect analytics overrides apply --from-file overrides.json --yes -p protect-prod

apply is convergent: an absent half of an entry means "no override" and apply clears it, so re-applying the same document is idempotent and the target ends up matching the document. apply reports and skips an entry naming an analytic the target lacks, and one naming a custom analytic; the rest still apply. It reports a refused entry and carries on, then exits non-zero at the end with both counts.

For a one-off adjustment, use set instead: it sends only the flags you pass, so an omitted flag leaves that half of the overlay alone:

# Downgrade one analytic without touching its actions
jamf-cli protect analytics overrides set BlazingKeylogger --severity Low

# Change the actions without touching the severity
jamf-cli protect analytics overrides set BlazingKeylogger \
  --action Report --action 'Alert={"notify":true}'

# Remove one half, or both
jamf-cli protect analytics overrides set BlazingKeylogger --clear-severity
jamf-cli protect analytics overrides clear BlazingKeylogger --yes

analytics list shows the published baseline severity. An analytic overridden to Low still reads High there. Use overrides list, which shows baseline, tenant and effective side by side. That baseline column is the usual reason an override looks as though it did not take.

protect backup captures the overlay as the analytic-overrides resource and protect restore replays it, so a whole-tenant capture covers this. Port it on its own when the severity policy needs promoting on its own schedule.

Download Installer Package

Download the Jamf Protect agent installer and related artifacts for deployment through Jamf Pro or another MDM.

# Check available downloads
jamf-cli protect downloads summary

# Download the installer package
jamf-cli protect downloads installer

# Download to a specific path
jamf-cli protect downloads installer -O /tmp/JamfProtect.pkg

# Download the uninstaller
jamf-cli protect downloads uninstaller

# Download all configuration profiles
jamf-cli protect downloads pppc-profile
jamf-cli protect downloads tamper-prevention-profile

# Download certificates
jamf-cli protect downloads root-ca
jamf-cli protect downloads csr

Download Configuration Profiles

Download plan-specific configuration profiles for MDM deployment.

# Download the mobileconfig for a plan
jamf-cli protect plans config-profile "Default Plan"

# Download a signed profile
jamf-cli protect plans config-profile "Default Plan" --sign

# Exclude specific payloads
jamf-cli protect plans config-profile "Default Plan" --no-pppc --no-system-extension

# Save to a specific path for MDM upload
jamf-cli protect plans config-profile "Default Plan" -O /path/to/deploy.mobileconfig

Manage USB Control Rules

Build and update removable storage control policies from the command line.

# Create a new USB control set
echo '{"Name":"Corporate USB Policy","DefaultMountAction":"Prevented"}' \
  | jamf-cli protect removable-storage-control-sets apply

# Add a vendor exception (allow SanDisk read-only)
jamf-cli protect removable-storage-control-sets add-rule "Corporate USB Policy" \
  --type vendor \
  --mount-action ReadOnly \
  --vendors "SanDisk"

# Add an encryption rule (allow encrypted volumes full access)
jamf-cli protect removable-storage-control-sets add-rule "Corporate USB Policy" \
  --type encryption \
  --mount-action ReadWrite

# Review the current state
jamf-cli protect removable-storage-control-sets get "Corporate USB Policy" -o json

# Remove the vendor rule
jamf-cli protect removable-storage-control-sets remove-rule "Corporate USB Policy" --type vendor

# Export the full set for version control
jamf-cli protect removable-storage-control-sets export "Corporate USB Policy" > usb-policy.json

Manage Exception Sets

Create and modify exception sets to reduce false positives without editing analytics.

# Create an exception set for development tools
echo '{"Name":"Developer Tools","Description":"Exceptions for dev workflows"}' \
  | jamf-cli protect exception-sets apply

# Add a path exception
jamf-cli protect exception-sets add-exception "Developer Tools" \
  --type Path \
  --value "/usr/local/bin/my-tool" \
  --ignore-activity IGNORE_ACTIVITIES

# Add another exception
jamf-cli protect exception-sets add-exception "Developer Tools" \
  --type Path \
  --value "/opt/homebrew/bin/brew"

# Review the exception set
jamf-cli protect exception-sets get "Developer Tools" -o json

# Remove an exception
jamf-cli protect exception-sets remove-exception "Developer Tools" \
  --type Path \
  --value "/opt/homebrew/bin/brew"

# Export for backup
jamf-cli protect exception-sets export "Developer Tools" > dev-exceptions.json

Group Unified Logging Filters into a Set (v1.27.0+)

Filter sets assign a batch of unified logging filters to a plan as a unit.

# Import the filters you want to group (community YAML schema)
jamf-cli protect unified-logging-filters import --dir ./ulf-filters/

# Create an empty set
echo '{"name":"Auth Logging","description":"Authentication and login events","filters":[]}' \
  | jamf-cli protect ulfs apply

# Add filters one at a time (idempotent)
jamf-cli protect ulfs add-filter "Auth Logging" --filter "Auth Events"
jamf-cli protect ulfs add-filter "Auth Logging" --filter "Screen Sharing"

# Attach the set to a plan
jamf-cli protect plans export "Production Plan" > plan.json
jq '.unifiedLoggingFilterSets += ["Auth Logging"]' plan.json \
  | jamf-cli protect plans apply --yes

# Confirm which plans use the set
jamf-cli protect ulfs get "Auth Logging" -o table

Note: A set assigned to a plan cannot be deleted until you detach it. Deleting an individual filter removes it from every set.

jamf-cli Wiki


Products

  • Jamf Pro: jamf-cli pro
  • Jamf Platform API: jamf-cli pro (blueprints, benchmarks, DDM reports)
  • Jamf Platform: jamf-cli platform (AI Governance, Jamf Account, audit)
  • Jamf Protect: jamf-cli protect
  • Jamf School: jamf-cli school
  • Jamf Security Cloud: jamf-cli security

Clone this wiki locally