Writing Intune Compliance Scripts with Claude: A Bash Workflow That Actually Ships

Microsoft made custom compliance settings for macOS generally available in the 2607 service release, the week of July 27, 2026. For anyone managing Macs in Intune, that is the most useful thing to land in compliance policy in years. Instead of being stuck with the built-in settings, you write a bash discovery script, pair it with a JSON rules file, and Intune flips a device compliant or non-compliant based on whatever you can measure from the command line.
Which means a lot of Mac admins are about to write a lot of bash. And a lot of them are going to ask an AI to write it.
That is fine — I do it. But "ask Claude for a compliance script" and "ship a compliance script Claude helped write" are two different activities, and the gap between them is where the outage lives. A discovery script that returns garbage does not fail loudly. It quietly marks devices non-compliant, and if you have Conditional Access tied to compliance, non-compliant means your users are locked out of mail. Here is the workflow that actually survives contact with a fleet.
Give it the constraints, not the task
The single biggest quality jump comes from pasting the platform rules into the prompt before you describe what you want. Anthropic's own guidance on prompting Claude Code says the same thing in general terms — reference specific constraints and point to existing patterns rather than describing intent and hoping. For Intune macOS discovery scripts, the constraint block is short enough to keep in a snippet:
Valid shebang, e.g. #!/bin/bash
UTF-8 encoded, no BOM
Exit code 0 for success, non-zero for failure
Script no larger than 1 MB, output no larger than 1 MB
Must finish in 10 minutes or less on macOS
Target bash 3.2 — /bin/bash on macOS is still 3.2.57, because anything newer is GPLv3
Nothing but the result goes to stdout; all logging goes to a file
That last one matters more than it looks. Intune parses what your script prints. A stray echo "checking FileVault..." corrupts the payload.
The output format itself is the part Microsoft's macOS documentation is thinnest on. The Windows page is explicit that the script must return compressed single-line JSON — return $hash | ConvertTo-Json -Compress, producing something like {"BiosVersion":"1.24","TPMChipPresent":true}. The macOS example in the docs shows a bare echo $ver with no setting name attached to it. Admins who have shipped this in production report that what actually works is the Windows behavior: one line of JSON to stdout, keyed on your setting names, booleans and integers unquoted, strings quoted. Tell Claude that explicitly, because if you don't, it will faithfully reproduce Microsoft's incomplete example.
Ask for the script and the JSON in the same pass
The discovery script and the rules file are one artifact split across two files, and the failure mode is a mismatch between them. SettingName in the JSON is case-sensitive and has to match the key your script emits exactly. Every setting the script returns needs a corresponding rule.
So ask for both at once, and give the model the schema: a Rules array, each rule carrying SettingName, Operator, DataType, Operand, MoreInfoUrl, and RemediationStrings with at least one en_US entry containing Title and Description. Operators are IsEquals, NotEquals, GreaterThan, GreaterEquals, LessThan, LessEquals. Data types are Boolean, Int64, Double, String, DateTime, Version. The whole policy caps at 100 KB and 100 rules.
Two things AI is genuinely good at here and that I no longer write by hand: choosing Version over String so that 26.1 compares correctly against 26.10, and drafting the remediation strings. Those strings are what your user sees in Company Portal when their Mac goes non-compliant, and they are the difference between a self-service fix and a ticket. Claude writes a better one than I do at 4pm on a Friday. Ask it to use the {ActualValue} token in the Title while it is at it, so the user sees the value that was actually found rather than a generic complaint.
Where it writes plausible garbage
Four failure modes, all of which look completely correct on the screen:
Bash 4 syntax. Associative arrays (declare -A), ${var,,} for lowercasing, mapfile. Every one of these is idiomatic modern bash and every one of them dies on macOS's 3.2. This is the most common thing I catch, and it happens because the training data is overwhelmingly Linux.
Assuming an interpreter is there. /usr/bin/python3 on macOS is a stub. It exists, and running it prompts the user to install the Command Line Developer Tools. Apple's guidance has been clear for years that future macOS versions will not include scripting language runtimes by default. A discovery script that shells out to python3 works beautifully on your Mac, which has Xcode on it, and fails across the fleet. Same principle for any binary that is not in the base OS: command -v it first, or don't use it.
Invented preference keys. This is the dangerous one. Ask for a check on some obscure setting and you will get a confident defaults read against a domain and key that reads plausibly and does not exist. The script runs, defaults returns an error, your value comes back empty, and the rule evaluates against nothing. Nothing about the output tells you it was fabricated. Every domain and key gets verified against a real machine or Apple's documentation before it ships — no exceptions.
Context confusion. Discovery scripts run as root unless you set Run this script using the logged on credentials to Yes. That means a defaults read for a user-domain preference — screen saver settings, for example — reads root's preferences, not the user's, and returns the wrong answer without erroring. You need sudo -u "$consoleUser" for those. AI gets this wrong regularly because it is a macOS-specific quirk that generic shell knowledge does not cover.
The verification loop is the whole job
The most useful line in Anthropic's Claude Code documentation is that you should give Claude a check it can run, because "Claude stops when the work looks done" and without a check, "looks done" is the only signal available. It also names the exact failure pattern relevant here: the trust-then-verify gap, where the model produces a plausible-looking implementation that does not handle edge cases. The fix is layered checks, and it helps to be honest about what each layer can and cannot see.
| Layer | Catches | Misses | |---|---|---| | Claude, first draft | Structure, JSON schema correctness, remediation copy, operator/data-type choice | Its own invented preference keys; anything requiring a real macOS to observe | | ShellCheck (brew install shellcheck) | Unquoted variables, constant test expressions, spaces around =, useless cat, portability warnings for the target shell | Whether the value you extracted is the right value | | Run on a test Mac as root | Bash 3.2 breakage, missing binaries, empty results from bad keys, stray stdout | Fleet diversity — different OS versions, secure-token states, MDM enrollment paths | | Pilot ring in Intune | Real check-in behavior, remediation strings as users see them, false-positive rate | Nothing, but it costs you eight hours per iteration |
Point Claude at ShellCheck and let it iterate until the linter is clean — that is a real pass/fail signal it can read, and it closes the loop without you in it. Then run the thing yourself and eyeball the JSON it prints. If it is not one line of valid JSON with your exact setting names as keys, nothing downstream will work.
When it does misbehave in the pilot, the Intune agent logs live in /Library/Logs/Microsoft/Intune (and ~/Library/Logs/Microsoft/Intune for the user context), as IntuneMDMDaemon date--time.log and IntuneMDMAgent date--time.log. Paste the relevant chunk into the session rather than describing the symptom.
What changes at fleet scale
At one machine, a bad compliance script is an annoyance. At ten thousand, it is a helpdesk event with an eight-hour feedback loop, because the agent evaluates on roughly an eight-hour cycle and a fixed device can take that long to report compliant again. That asymmetry is the whole argument for the review layers above: the cost of one more ShellCheck run is thirty seconds, and the cost of shipping a fabricated preference key to a full ring is a day of tickets and a lot of goodwill.
It is also worth noting the deployment friction. The script upload workflow does not support scope tags — you have to be assigned the default scope tag to create, edit, or view custom compliance discovery scripts. If your org has carved up Intune by scope tag, sort that out before you write anything, not after.
The practical takeaway
Use AI for the first draft and the JSON, always. It is faster than you at both, and materially better at remediation strings. Do not use it as the last word on anything it cannot observe — every preference domain, key, and command path gets verified against a real Mac before it goes anywhere near a ring.
Concretely: keep a constraints snippet (bash 3.2, single-line JSON keyed on setting names, exit codes, nothing else to stdout) and paste it at the top of every request. Ask for the script and the JSON together. Make ShellCheck the gate the model has to pass on its own. Run it as root on a test machine and read the raw output with your own eyes. Then pilot to a small ring before anything wider.
The version of this that fails is the one where you skip the middle two steps because the script looked right. It always looks right. That is the entire problem.
Sources: Microsoft Learn — What's new in Microsoft Intune, Microsoft Learn — Custom compliance discovery scripts, Microsoft Learn — Create discovery scripts for custom compliance policy, Microsoft Learn — Custom compliance JSON files, Microsoft Learn — Use shell scripts on macOS devices, SS Mac Admin — Custom Compliance for macOS in Intune, Intune In Real Life — Custom Compliance Comes to macOS, Anthropic — Best practices for Claude Code, Scripting OS X — Moving to zsh, Scripting OS X — Wrangling Pythons, ShellCheck



Comments