Generate production-ready .mobileconfig files (Apple Configuration Profiles) for macOS, iOS, iPadOS, tvOS, watchOS, and visionOS — validated against Apple's official device-management schema.
Who it is for: MDM and Apple admins. If you keep a fleet running in Jamf Pro or Microsoft Intune, in Kandji, Mosyle, or Apple Profile Manager, and you need a profile whose keys are right the first time, this is aimed at you.
Two ways to use it. SKILL.md makes this repository a Claude skill: Claude reads the workflow there, asks for platform, payload type, and the required keys, and hands back a finished profile. scripts/ makes it a command line tool for people: three Python scripts you run in a terminal or wire into your own pipeline, with no Claude involved. Both paths call the same code and share the same schema cache.
.mobileconfig files configure Apple devices: Wi-Fi, VPN, email, certificates, restrictions, FileVault, software update policies, and more. They're deployed via MDM (Jamf, Intune, Kandji, Mosyle), Apple Configurator, AirDrop, or manual install.
This tool:
- Fetches the current YAML schemas from github.com/apple/device-management (release branch)
- Validates every payload against the official schema (required keys, types, value ranges)
- Builds a correctly structured XML plist
.mobileconfigfile - Signs (optional) with PKCS#7, either from PEM files through OpenSSL or from an identity in the macOS keychain, where the private key never leaves the keychain
Two ways, no package manager involved.
Clone the repository. You get everything: the scripts, the skill, the
examples, plus the landing page, the CI workflows and tools/scan_secrets.py.
git clone https://github.com/GodModeAI2025/mobileconfig-builder.git
cd mobileconfig-builder
python3 scripts/fetch_schema.pyDownload the release archive. It holds what you need to run the tool or
install the skill and nothing else: SKILL.md, references/, scripts/,
assets/, evals/, plus LICENSE, NOTICE and VERSION. The landing page
and the workflow files stay out, and so does scripts/package_release.py,
which builds the archive and has no job inside it.
curl -LO https://github.com/GodModeAI2025/mobileconfig-builder/releases/latest/download/mobileconfig-builder.zip
unzip mobileconfig-builder.zip -d ~/.claude/skills/The archive unpacks into a single mobileconfig-builder/ directory, so the
command above lands the skill at ~/.claude/skills/mobileconfig-builder/,
where Claude looks for it. For command line use, unzip wherever you like and
run the scripts from that directory.
That URL always points at the newest published release, so it answers with 404 as long as this repository has no release. In that case, clone.
# 1. Fetch all Apple profile schemas (cached locally)
python3 scripts/fetch_schema.py
# 2. Inspect a payload type
python3 scripts/inspect_payload.py com.apple.wifi.managed --os macOS
# 3. Build a profile from a spec file
python3 scripts/build_mobileconfig.py assets/examples/wifi_guest.json -o wifi.mobileconfig --validate-strict
# 4. Check a profile you already have, no matter where it came from
python3 scripts/validate_mobileconfig.py wifi.mobileconfig- Python 3.9+
- PyYAML (auto-installed on first run)
- OpenSSL (only for signing)
Create a JSON file with your profile configuration:
{
"meta": {
"PayloadIdentifier": "com.example.wifi.guest",
"PayloadDisplayName": "Guest Wi-Fi",
"PayloadDescription": "Configures the guest Wi-Fi network",
"PayloadOrganization": "Example Corp",
"PayloadScope": "System"
},
"payloads": [
{
"PayloadType": "com.apple.wifi.managed",
"PayloadDisplayName": "Wi-Fi: GuestNet",
"SSID_STR": "GuestNet",
"AutoJoin": true,
"EncryptionType": "WPA",
"Password": "supersecret123"
}
]
}| Script | Purpose |
|---|---|
scripts/fetch_schema.py |
Fetches and caches YAML schemas from Apple's GitHub repo. Supports --offline, --from-clone, --list, --refresh. |
scripts/inspect_payload.py |
Displays keys, required fields, types, and allowed values for any PayloadType. Supports OS filtering. |
scripts/build_mobileconfig.py |
Builds and validates the profile. Outputs unsigned or PKCS#7-signed .mobileconfig. |
scripts/validate_mobileconfig.py |
Checks a .mobileconfig that already exists, whoever built it. Reads XML plists, binary plists, and signed PKCS#7 containers. Reports findings with a path, --format json for machines, exit code 0/1/2. |
scripts/package_release.py |
Builds the release archive from the files git tracks. Takes the output path as its argument, needs no network, and produces the same bytes on every run. |
tools/scan_secrets.py |
Repository check, not part of the build workflow. Looks for committed profiles, key files, PEM blocks, and password values that are not documented placeholders. Runs in CI. |
- Schema-validated: Every key checked against Apple's official YAML definitions, top-level fields against
TopLevel.yamland each payload against its own schema - Validates profiles it did not build: hand it a
.mobileconfigexported from Jamf, Intune, Kandji or Profile Manager and it checks that file against the same rules, signed containers included - Third-party domains:
--manifestsadds ProfileManifests as a second source, so Chrome, Office, Zoom and the rest validate too. Apple wins wherever both describe a payload type - Deterministic UUIDs: Same input always produces the same UUIDs (safe for re-deployment)
- Multi-payload support: Combine Wi-Fi + Restrictions + Certificates in one profile. A certificate payload carries a
<data>key, and a JSON spec reaches it through{"__base64__": "..."}or{"__file__": "ca.der"}; YAML keeps the!!binarytag. A bare base64 string is still a string and still fails asexpected <data>, got str - OS-aware inspection: Filter keys by target platform (macOS, iOS, tvOS, etc.)
- Offline mode: Works fully offline once schemas are cached
- Optional signing: PKCS#7 signing for production MDM deployment, either with OpenSSL from PEM files or with
security cmsfrom a keychain identity
Rule: unsigned is for the lab. An unsigned profile is fine while you are iterating on a spec on your own test devices. Anything that leaves that bench, for a colleague's Mac, a fleet, or an MDM server, gets signed. Without a signature the device shows "Not Verified" at install time, nobody can tell who built the file, and whoever gets hold of it before installation can edit it. Plenty of MDM servers refuse unsigned profiles outright.
There are two ways in, and they produce the same PKCS#7 DER file.
From PEM files, through OpenSSL, on any platform:
python3 scripts/build_mobileconfig.py spec.json \
-o profile.mobileconfig \
--sign-cert signer-cert.pem \
--sign-key signer-key.pem \
--sign-ca ca-chain.pemFrom the macOS keychain, through /usr/bin/security cms, where the private
key stays in the keychain. This is the usual case in a company, because a
signing key that arrives by SCEP or ADCS is marked non-exportable and OpenSSL
cannot read it at all:
security find-identity -v -p smime # list the candidates
python3 scripts/build_mobileconfig.py spec.json \
-o profile.mobileconfig \
--sign-identity "Profile Signer 2026"Use -p smime or -p basic to find the identity, not -p codesigning. A
profile signer carries the emailProtection EKU or no restricting EKU at
all, so the code-signing policy hides it. On a managed Mac the three lists
genuinely differ and none of them contains the others.
The flag is optional, the practice is not. Signing also does not remove the
install prompt: the device still asks the user to confirm, and it only shows
the profile as verified when it already trusts the signing CA. A self-signed
certificate without established trust looks the same as no signature at all.
references/signing.md covers how to pick a certificate, what the keychain
access dialog does on the first call, and how to prepare a keychain for an
unattended run.
A certificate payload carries its bytes in a <data> key. YAML has the
!!binary tag for that; JSON has no bytes type at all, which used to make
every certificate payload a YAML-only affair. Two markers close that:
{
"payloads": [
{
"PayloadType": "com.apple.security.root",
"PayloadCertificateFileName": "ca.cer",
"PayloadContent": {"__file__": "ca.der"}
}
]
}{"__base64__": "MIIDXTCC..."} takes the base64 text directly, line breaks
and spaces included, which is what you get from a PEM body or from
base64 < ca.der. {"__file__": "ca.der"} reads a file; a relative path
counts from the directory of the spec, not from the working directory, so the
same spec builds the same profile from anywhere. Both are resolved before
validation, anywhere in the spec, at any depth, in dictionaries and in lists.
Apple wants DER on a certificate payload. A PEM file goes in as PEM bytes, because the marker copies what it reads, so convert first:
openssl x509 -in ca.pem -outform der -out ca.derThe marker replaces its whole dictionary, so nothing else may stand next to it, and a value that is not valid base64 or a path that cannot be read ends the run with exit code 2 and a message naming the spot, before any file is written.
build_mobileconfig.py validates while it builds. validate_mobileconfig.py
takes the other direction: a finished .mobileconfig, whoever wrote it.
python3 scripts/validate_mobileconfig.py profile.mobileconfig
python3 scripts/validate_mobileconfig.py *.mobileconfig --strict
python3 scripts/validate_mobileconfig.py profile.mobileconfig --format jsonIt reads three shapes and tells you which one it got: an XML plist, a binary
plist, and a signed PKCS#7 container, which it unwraps with
openssl smime -verify -noverify before looking inside. The profile level
goes against TopLevel.yaml, every entry in PayloadContent against its own
schema, which is the same code path the build uses.
Findings come in two levels, and one rule separates them:
| Level | Meaning | Examples | Exit |
|---|---|---|---|
FEHLER |
The schema is violated | required key missing, wrong type, value outside rangelist or range, PayloadContent missing or not a list |
2 |
WARNUNG |
The schema says nothing, or there is none | key not in the schema, payload type without a schema, a value failing a format regex, a PayloadUUID used twice |
1 |
--manifests works here too, and a run that used it says so in its report,
because a clean result whose rule came from a community source without a
license is not the same clean result as one against Apple's own schema.
The split is what makes the tool usable on foreign files. A profile out of a
real MDM regularly carries keys Apple never described and payloads from
vendors Apple has no schema for. As errors those would paint every such file
red. --strict turns every warning into an error, which is the mode for
profiles that come out of this repository.
Both levels are non-zero, so either one already fails a CI job. Use --strict
when a warning should count as a defect:
- name: Validate profiles
run: |
python3 -m pip install pyyaml
python3 scripts/fetch_schema.py
python3 scripts/validate_mobileconfig.py profiles/*.mobileconfig --strictThere is no packaged GitHub Action for this yet; the snippet calls the script
directly, and it needs the schema cache, so fetch_schema.py runs first.
A configuration profile carries credentials in the clear. The Wi-Fi payload
holds the network password, a VPN payload holds the shared secret, a mail
payload holds account data. The XML plist stores all of it unencrypted, so a
.mobileconfig file is exactly as sensitive as the passwords inside it, and
so is the spec file it was built from.
What that means in practice:
- Write output outside the repository. The quick start above uses
-o wifi.mobileconfigin the current directory because it is short; in real use, point-oat a path you control..gitignorecovers*.mobileconfigand key material (*.pem,*.key,*.p12,*.pfx,*.cer,*.crt) as a backstop for the times you forget. - Treat spec files like the profiles they produce. A spec with a real Wi-Fi password does not belong in version control either.
- Never send a private key through a chat window.
SKILL.mdinstructs Claude to refuse that and ask for a file path on your machine instead. - Hand the profile to the MDM server, then delete the local copy, or keep it where you keep other credentials.
- The example specs under
assets/examples/use invented passwords (supersecret123,schoolpass2026). They are listed as placeholders intools/scan_secrets.py; any other value behind a key likePassword,SharedSecret, orPassphrasemakes the scan and the CI job fail. The one exception is a boolean:PasswordManagerEnabled: falseis a Chrome policy, not a credential, andtrue/false/yes/nopass.
Run the check yourself with python3 tools/scan_secrets.py. It reads only
what git ls-files reports, needs no network, and prints file and line for
every finding. It has no entropy heuristic and does not look at history, so
it complements a real scanner such as gitleaks rather than replacing it.
- macOS: Double-click → System Settings → Privacy & Security → Profiles → Install
- iOS/iPadOS: Open via AirDrop/Mail/Safari → Settings → "Profile Downloaded" → Install
- MDM: Import into Jamf, Kandji, Mosyle, Intune, or Apple Profile Manager
assets/examples/wifi_guest.json— Simple WPA Wi-Fi profileassets/examples/classroom_ipad.json— Wi-Fi + iPadOS Restrictions combinedassets/examples/encrypted_dns.json— DNS over HTTPS with one internal zone excluded
python3 evals/run_tests.py # Run all 11 eval tests
python3 evals/run_tests.py -v # Verbose output
python3 evals/run_tests.py --eval-id 4 # Run a single testThe suite calls every script with --offline, so a populated schema cache is a prerequisite. Run python3 scripts/fetch_schema.py once, or fill the cache from a local clone with --from-clone.
CI runs the same suite on every push and pull request against main, plus nine checks outside the test runner: VERSION against the CHANGELOG.md section and against the download name this README documents, the validator run against a built profile and against three broken copies of it, the invented top-level key sent straight through the CLI, both signing paths driven against the output path, a certificate payload built from a JSON spec through both data markers, a schema inspection of the Wi-Fi payload, a Chrome profile built against ProfileManifests at a pinned commit, tools/scan_secrets.py, and a build of the release archive. That last one asserts the files the archive has to contain, the repository internals it must not contain, and identical bytes on two consecutive runs, so a broken package shows up before someone sets a tag rather than after. Eval 6 already covers the top-level rejection inside the suite; the CI step asserts the same contract at the shell level, where the exit code and the missing output file are what a caller actually sees. The signing step ends by handing its own signed output to the validator, and then the same file with one changed byte: that is the only place in this workflow where a PKCS#7 container exists, so it is the only place where unwrapping one can be measured. The certificate step generates its own throwaway certificate with openssl, because no certificate may live in this repository, and asserts that both markers put the same bytes into the profile that the DER file holds. The Wi-Fi inspection is the only coverage inspect_payload.py gets, since no eval calls it. The Chrome step is the only one that reaches ProfileManifests: it asserts that the payload type is rejected without --manifests, accepted with it, that an invented Chrome key still fails, and that no manifest ends up in the working tree. Both schema sources are pinned to a fixed commit, so a change upstream cannot turn the build red by itself. Bumping either commit is a deliberate edit in .github/workflows/ci.yml.
Apple's YAML covers Apple's domains. A payload of type com.google.Chrome,
com.microsoft.office or us.zoom.config is rejected under
--validate-strict as an unknown PayloadType, because Apple never described
it. --manifests adds
ProfileManifests as a
second source, the collection that also powers ProfileCreator and iMazing
Profile Editor.
python3 scripts/inspect_payload.py com.google.Chrome --manifests
python3 scripts/build_mobileconfig.py chrome.json \
-o chrome.mobileconfig --validate-strict --manifestsApple wins. ProfileManifests is only consulted for payload types Apple does
not describe at all, and nothing is merged, so it is always clear which rule
applied. For PPPC the difference is one key: Apple's TCC schema lists 24
services, ProfileManifests lists 25, and the extra one is RemoteDesktop.
Every run that used the second source says so on stderr, and
inspect_payload.py prints the origin in its header.
ProfileManifests has no license. No LICENSE file, and the GitHub API
reports "license": null (checked 2026-09-04). Without a license there is no
grant to redistribute, so nothing from it lives in this repository, nothing
ships in the release archive, and no manifest is used as a test fixture.
--manifests fetches one file at runtime into
~/.cache/mobileconfig-builder/profilemanifests/<ref>/. Pin the ref with
--manifests-ref <sha> when you want the same schema on the next run;
without it the default is the master branch. references/schema-format.md
documents how the manifest fields are translated and what is deliberately
dropped.
| Use Case | PayloadType |
|---|---|
| Wi-Fi | com.apple.wifi.managed |
| VPN | com.apple.vpn.managed |
| Mail Account | com.apple.mail.managed |
| Exchange | com.apple.eas.account |
| Restrictions (iOS) | com.apple.applicationaccess |
| Restrictions (macOS) | com.apple.applicationaccess.new |
| Certificate | com.apple.security.pkcs1 / .pkcs12 / .root |
| FileVault | com.apple.MCX.FileVault2 |
| Software Update | com.apple.SoftwareUpdate |
| Privacy/TCC | com.apple.TCC.configuration-profile-policy |
<data>markers take bytes at face value.{"__base64__": "..."}and{"__file__": "..."}are resolved anywhere in the spec tree, before validation and regardless of what the schema expects at that spot, because binding the resolution to<data>would mean knowing the schema before the spec exists. A marker in the wrong place shows up asexpected <string>, got bytes.__file__reads whatever path it is given, with no size limit, and puts those bytes into the profile as they are: a PEM file lands as PEM, and Apple wants DER on a certificate payload, so convert withopenssl x509 -outform derfirst. Neither marker checks that the bytes are a certificate.~is expanded, an absolute path is taken as it stands, and there is no allowlist, so a spec is only as trustworthy as its source: read a foreign spec's__file__entries before you build it, or it pulls~/.ssh/id_rsainto a profile in plain text.- The schema cache never expires. A cached file is served until you run
fetch_schema.py --refresh. A payload type Apple adds shows up on the next online fetch because the file is missing locally, but keys Apple changes inside an existing file stay stale until a refresh. - The validator checks the schema, not the deployment.
validate_mobileconfig.pyanswers one question: do the keys, types and value ranges in this file match Apple's YAML? It says nothing about whether the profile does what you meant, whether the target OS supports the keys it carries (supportedOSis not evaluated, so an iOS-only key in a macOS profile passes), or whether an MDM will accept it. It does not verify who signed a file:openssl smime -verify -noverifychecks the signature but skips the certificate chain, so a valid signature from an issuer nobody trusts passes. Encrypted payloads stay opaque,EncryptedPayloadContentis data and is not unwrapped. Output is text or JSON; there is no SARIF, so findings do not land in GitHub Code Scanning. - The second schema source is a community source. ProfileManifests is maintained by Mac Admins, not by Google, Microsoft or Zoom. A green
--validate-strictagainst a manifest means the keys and types match what the community wrote down. The manifest fieldspfm_conditionals,pfm_exclude,pfm_targetsandpfm_app_minare not translated, so a profile that violates those rules passes here. - Keychain signing is macOS only, and its success path is not in CI.
--sign-identitygoes through/usr/bin/security, which exists on macOS and nowhere else; on any other platform it exits 2 and points at--sign-cert. Signing from PEM files still needsopensslinPATH. Eval 7 covers the failure paths of both on every platform. The success path of the keychain route was verified by hand against a throwaway keychain;references/signing.mdhas the import andset-key-partition-listcommands that make an unattended run possible. It also cannot sign when two certificates in the keychain share a common name:security cms -Nselects by name alone and takes no fingerprint, so the tool refuses instead of signing with a certificate it cannot name. Passing the SHA-1 does not get around that, and the PEM route does, because there the certificate itself is the argument. - Signing needs a writable output directory, not just a writable output file. The signature is written to a temporary file next to the target and moved into place with
os.replace, so that a failed run cannot leave behind the empty.mobileconfigthatopenssl -outused to truncate the previous valid profile into. The move needs write permission on the directory. In a directory with mode 555 the run ends with exit 2 and says so, and whatever was at the output path stays untouched. The old direct write failed there too, with aPermissionErrortraceback, because it put a<name>.unsigned.mobileconfignext to the target and needed the same permission. - PyYAML is installed at runtime. On first use the scripts run
pip install pyyamlwhen the module is missing, and retry with--break-system-packagesfor a Python whose packages the system manages. If both attempts fail, the script names the pip command to run by hand and exits 2. On a locked-down machine, install it yourself first. - Encrypted payloads are out of scope. Apple allows a payload to be encrypted for one specific device. This tool writes plain text payloads only, which is why the secrets section below matters.
- DDM is not covered. See the note below.
- CI covers one Python version. The workflow runs on Python 3.12 with a current pip, against a pinned schema commit. The floor the requirements section names is checked by hand: for this release the eval suite, the CLI checks and the PyYAML auto-install were run on the Python 3.9 and pip 21.2.4 that ship with macOS. The current Apple schema is checked by hand too.
Candidates, in no particular order, and none of them promised:
- Make the cache directory configurable through an environment variable instead of the fixed
~/.cache/mobileconfig-builder/. - DDM declarations from the same spec. The biggest one, and the one that decides whether this tool still matters in two years.
references/ddm.mdhas the full write-up; the short version is below.
Apple ships new functionality to DDM first. DDM declarations are JSON, not
.mobileconfig, they live on the MDM server rather than in a file you can
hand someone, and the device holds the state and reports back instead of
installing a document. This tool builds Configuration Profiles and nothing
else today.
The interesting part is what a spec file actually is here. It describes
intent, not a file format: a passcode policy, a Wi-Fi network, a set of
restrictions. The same intent has a DDM shape, and Apple describes both sides
in the same YAML format, with the same payloadkeys, type, presence,
range and subkeys fields. The validator in this repository would work on
declarations unchanged. One spec, two outputs, one set of rules.
What stops that from being a weekend job:
- The mapping is a translation, not a rename. For the passcode policy, 11
of 13 keys are pure renames (
forcePINtoRequirePasscode), one inverts its meaning (allowSimple: falseequalsRequireComplexPasscode: true), and one moves its allowed range (maxPINAgeInDaysstarts at 1,MaximumPasscodeAgeInDaysat 0). A table that misses cases like these produces output that is schema-valid and factually wrong. - Most payloads have no counterpart. The
releasebranch has 121 payload types undermdm/profiles/and 36 configuration declarations underdeclarative/declarations/configurations/. Wi-Fi, VPN, certificates and the whole restrictions family are not among them. com.apple.configuration.legacyis the honest bridge. Its only required key isProfileURL: the declaration points at a profile that stays a profile. A useful export would go native where it can and fall back tolegacyfor the rest, and say which of the two happened for every payload. An export that hides that difference is worse than none.
Order of work, if it gets built: fetch the declaration schemas, then an
inspect_declaration.py, then a hand-maintained mapping table, then the
export. Step two already pays for itself, because it answers the question
whether a given payload has a declaration at all. Locally only schema
validity is testable; whether a device accepts a declaration shows up on an
MDM server.
The version lives in VERSION at the repository root, one line, semantic
versioning. Nothing else keeps a copy of it: CHANGELOG.md gets the matching
section, the archive carries the file itself, and CI fails if the two drift
apart.
Releasing is three steps, and the last one is deliberate handwork:
- Set
VERSION, add the section toCHANGELOG.md, merge tomain. git tag v$(cat VERSION)and push the tag..github/workflows/release.ymlchecks the tag againstVERSION, runsscripts/package_release.py, and creates the release as a draft withmobileconfig-builder.zipattached. Read it, write the release text, publish.
To see what a release will contain before tagging:
python3 scripts/package_release.py dist/mobileconfig-builder.zip
unzip -Z1 dist/mobileconfig-builder.zipThe 0.x number is deliberate. The known gaps are listed under Limitations, and the command line interface may still change between minor versions.
Apache License 2.0, see LICENSE.
The Apple profile schemas this tool validates against come from
apple/device-management and are
MIT-licensed by Apple Inc. They are fetched at runtime into
~/.cache/mobileconfig-builder/ and are not part of this repository. See
NOTICE.