fix(mdm): make the discovery key optional and resolve the device owner's key from the admin key + serial; fix the 404 usage URL and swallowed exit code in onboard.ps1 - #289
Conversation
…fall back to the admin key) The dashboard's generated MDM onboard command stopped emitting -DiscoveryKey / --discovery-key (unbound-fe #1999, WEB-5597) because the backend now accepts the admin key for discovery uploads. onboard.ps1 and onboard.py still hard-required it and exited 1 with "-DiscoveryKey is required", which failed Xome's whole Intune rollout. - onboard.py: --discovery-key is optional; when absent the discovery step runs with the --api-key value. Explicit --discovery-key still wins. --api-key with no/empty value now fails the wrapper's own check instead of being passed through to every per-tool script. - onboard.ps1: -DiscoveryKey optional, defaults to -ApiKey (passed explicitly so the fallback holds regardless of onboard.py revision). Usage/examples now point at https://getunbound.ai/setup/mdm/windows/onboard (the old /setup/mdm/onboard.ps1 URL returns 404). - mdm/README.md: drop "separate key required" wording and examples. - claude-code/hooks/mdm/setup.py: raw docstring for the one containing `<home>\AppData\Roaming`, which raised SyntaxWarning: invalid escape sequence '\A' on Python 3.12+. - tests/test_mdm_onboard.py: argv-level contract for onboard.main(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NYCyXQWih1Ujk9ScSpjvrp
Bugbot needs on-demand usage enabledBugbot uses usage-based billing for this team and requires on-demand usage to be enabled. A team admin can enable on-demand usage in the Cursor dashboard. |
`$exitCode = Main` captured Main's whole success stream, i.e. every line the Python driver and the per-tool setup.py scripts wrote to stdout, and `exit` on the resulting Object[] returned 0. Measured on a Windows VM: a Main whose python printed one line and exited 3 gave captured-type=Object[] count=2 value=[py-stdout-line 3] and a cmd-level exit code of 0. So customers saw only stderr in their logs, and Intune remediation saw success even when onboarding failed. Main is now invoked bare so native stdout flows to the host; the Python exit code is stashed in $script:pythonExitCode (defaulted to 1 before Main so an early stop can't leak a 0) and used by the final `exit`. Exit-WithError paths and the self-destruct block are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NYCyXQWih1Ujk9ScSpjvrp
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
0 findings — 0 high-confidence, 0 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks)
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 7551a377 · 2026-09-03T01:40Z
| i = args.index(flag) | ||
| except ValueError: | ||
| return None | ||
| return args[i + 1] if i + 1 < len(args) else None |
There was a problem hiding this comment.
Option token bypasses key validation
When --api-key is followed by another option such as --backend-url, _flag_value accepts that option token as the key, causing every setup step and discovery to run with a bogus credential instead of returning the intended --api-key is required error.
| return args[i + 1] if i + 1 < len(args) else None | |
| if i + 1 >= len(args) or args[i + 1].startswith("--"): | |
| return None | |
| return args[i + 1] |
Knowledge Base Used: MDM onboarding automation
|
Verified on a fresh Azure Windows Server 2022 VM, run as SYSTEM via
|
…r the admin key itself Scanning with the admin key authenticates but mis-attributes: the backend (ai-gateway-data webapp/tasks/ai_tools_report_tasks.py:185-200) attributes an application-key-authenticated report to that key's OWNER and skips the MDM serial lookup, so a fleet would show every device under the admin. unbound-cli #82 solved this by exchanging admin key + hardware serial for the device owner's key; onboard.py now does the same. - onboard.py: get_device_serial() (ioreg / dmidecode + /sys dmi / Win32_BIOS→MachineGuid→hostname, copied from claude-code setup.py), fetch_owner_key() via urllib against /api/v1/automations/mdm/get_application_api_key/?serial_number=&app_type=default with Bearer <admin key>, 20s timeout, one retry. Precedence: explicit --discovery-key > owner key > Discovery step FAILED with the cause named. Never falls back to the admin key. Steps 1-5 run regardless. Prints "[Discovery] scanning with the device owner's key (serial X)". - onboard.ps1: only forward --discovery-key when -DiscoveryKey was given; onboard.py owns the resolution. Exit-code and URL fixes kept. - README + docstring/USAGE updated. - tests: exchange happy path (URL, header, timeout, info line, no keys printed), explicit key skips exchange, URLError/HTTP 404/no api_key/bad JSON → Discovery failed + exit 1 with tools still run, missing serial same, plus the existing api-key/--clear cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NYCyXQWih1Ujk9ScSpjvrp
|
Attribution verified end to end on the production read replica (org 5, Unbound's own admin key), two fresh Win2022 VMs as SYSTEM:
Neither serial is imported, so the correct outcome for both is "the serial's own user", which only e8f6433 produces. On a fleet with imported serials the exchange resolves to the real device owner. e8f6433 run (exit 0, 279 log lines):
|
|
Third data point, same replica, same org 5, third fresh VM ( |
Bugbot needs on-demand usage enabledBugbot uses usage-based billing for this team and requires on-demand usage to be enabled. A team admin can enable on-demand usage in the Cursor dashboard. |
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 1 high-confidence, 2 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.
🟡 TRIAGE — Privileged serial probes resolve helpers via PATH
- File:
mdm/onboard.py:326,mdm/onboard.py:337,mdm/onboard.py:352 - Impact: New
_run_stdoutinvokesioreg,dmidecode, andpowershellby bare name while running as SYSTEM (Windows Intune) or root (sudo); a writable earlyPATHentry can turn the next remediation run into arbitrary code execution as that privileged principal. - Fix: Invoke known absolute paths only (e.g.
%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe,/usr/sbin/ioreg,/usr/sbin/dmidecodewith/usr/bin/dmidecodefallback); skip the source if the binary is missing rather than falling back toPATH. - Reviewers: Claude
🔴 HIGH — --api-key accepts the next flag token as the key value
- File:
mdm/onboard.py:502 - Impact:
_flag_valuereturnsargs[i + 1]even when that token is another option (e.g.--api-key --backend-url …), so onboarding and discovery run with a bogus credential instead of the intended--api-key is requirederror. - Fix: Return
Nonewhen the following arg is missing or starts with--(same guard greptile suggested on the inline thread). - Reviewers: Greptile (inline P1), Cursor
🟡 TRIAGE — --backend-url is not validated before admin-key exchange
- File:
mdm/onboard.py:404 - Impact: This PR adds a new outbound call that sends
Authorization: Bearer <admin key>to{backend_url}/api/v1/automations/mdm/...; a mistyped or tampered-BackendUrl/--backend-urlcan exfiltrate the fleet admin key over cleartexthttpor to an attacker-controlled host, with little local trace after wrapper self-destruct. - Fix: Parse with
urllib.parse.urlparseand reject non-httpsschemes (optional explicit localhost/dev opt-in); log the exchange host in the[Discovery]info line so operators can verify the destination. - Reviewers: Claude
Note: Semgrep reported pickle usage and permissive 0o755/0o700 modes in claude-code/hooks/mdm/setup.py and mdm/onboard.py:272; those hits sit on pre-existing code paths not materially changed in this diff (this PR only adds a raw docstring there). Gitleaks reported no secrets.
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head e8f64330 · 2026-09-03T03:31Z
Regression
unbound-fe #1999 (WEB-5597, in prod since 2026-09-01) dropped
-DiscoveryKey/--discovery-keyfrom the MDM onboard commands the dashboard Configure page generates, because the backend's discovery auth (_validate_discovery_authin ai-gateway-data) now accepts application/admin keys.The wrappers served at
https://getunbound.ai/setup/mdm/windows/onboard(mdm/onboard.ps1) andhttps://getunbound.ai/setup/mdm/onboard(mdm/onboard.py) were not updated and still hard-required the key:Every device in Xome's Intune rollout hit that and exited 1 before running a single MDM step.
Why "just use the admin key" is wrong
The admin key authenticates, but it mis-attributes. Backend precedence in ai-gateway-data
webapp/tasks/ai_tools_report_tasks.pylines 185-200: when a discovery report is authenticated with an application key (the admin key is one), the device is attributed to that key's owner and the MDM serial lookup is skipped. Only the old org discovery key takes the serial-lookup path. On a fleet, every device would show under the admin. unbound-cli #82 solved this for the CLI by exchanging admin key + hardware serial for the device owner's key and scanning with that; this PR does the same inonboard.py.Fix
mdm/onboard.py--discovery-keyis optional. When absent,run_discovery_stepresolves the device owner's key:get_device_serial()reads the hardware serial live: macOSioreg -rd1 -c IOPlatformExpertDevice→IOPlatformSerialNumber; Linuxdmidecode -s system-serial-numberthen/sys/class/dmi/id/product_serial; Windows the same chain asget_device_identifier()inclaude-code/hooks/mdm/setup.py(Win32_BIOSserial →MachineGuid→ hostname), copied rather than imported since the script is standalone.fetch_owner_key()→GET {backend}/api/v1/automations/mdm/get_application_api_key/?serial_number=<serial>&app_type=defaultwithAuthorization: Bearer <admin key>, stdliburllib, 20 s timeout, one retry.resp["api_key"]and prints[Discovery] scanning with the device owner's key (serial <serial>)(keys are never printed).--discovery-key(unchanged, deprecated) > owner key from the exchange > Discovery step fails with the cause named (no serial / HTTP error / noapi_key/ bad JSON). It deliberately never falls back to the admin key. Steps 1-5 run regardless, and the run exits 1 so remediation retries.--api-keyis now checked for a value: a bare/empty flag fails the wrapper's own check instead of being handed to all six per-tool scripts.--discovery-keyis still never forwarded to them.mdm/onboard.ps1-DiscoveryKey is requiredgate is removed;-ApiKeystays required.--discovery-keyis only forwarded when-DiscoveryKeywas actually passed;onboard.pyowns the resolution..EXAMPLEs and the-ApiKey is requiredusage string now point athttps://getunbound.ai/setup/mdm/windows/onboard. The URL they used to print,https://getunbound.ai/setup/mdm/onboard.ps1, returns HTTP 404.$exitCode = Maincaptured Main's entire success stream, i.e. every lineonboard.pyand the per-toolsetup.pyscripts print to stdout, andexiton the resultingObject[]returned 0. Measured on a Windows VM: aMainwhose native python printed one line and exited 3 gavecaptured-type=Object[] count=2 value=[py-stdout-line 3]and a cmd-level exit code of 0. In production that meant (a) only stderr ever reached the customer's log, and (b) Intune remediation saw exit 0 even when the Python driver failed.Mainis now invoked bare so stdout flows to the host; the exit code is stored in$script:pythonExitCode(defaulted to 1 beforeMainso an early stop can never leak a 0) and used by the finalexit.Exit-WithErrorpaths and the self-destruct block are unchanged.mdm/README.md– drops the "separate key required" wording and the-DiscoveryKey/--discovery-keylines from every example; documents the serial exchange and the deprecated override.claude-code/hooks/mdm/setup.py–_claude_desktop_support_dirsdocstring contains<home>\AppData\Roamingin a non-raw string, which Python 3.12+ reports asSyntaxWarning: invalid escape sequence '\A'on stderr at compile time. Made it a raw docstring. The other eight*/mdm/setup.pyfiles were checked the same way and are clean.Testing
tests/test_mdm_onboard.pydrivesonboard.main()throughsys.argvwith the admin check, downloads, subprocesses and serial stubbed andurllib.request.urlopenscripted:…/get_application_api_key/?serial_number=SER123&app_type=default, headerAuthorization: Bearer ADMIN, timeout 20; per-tool args are exactly--api-key ADMIN; the info line is printed and no key appears in stdout--discovery-keywins, skips the exchange, and is never forwarded to per-tool scripts--backend-urlis used for both the exchange URL and discoveryURLError×2, HTTP 404 ×2, body withoutapi_key, non-JSON body) → Discovery counted as failed, exit 1, tools still ran, retry count as expected, cause named on stderr--discovery-keyonly /--api-keybare / empty) and nothing runs--clearneeds no keys and skips discoveryResults (CI command
python -m pytest -q, pytest 9.0.2):Also:
get_device_serial()returns a real serial on a Mac under the system Python 3.9;python -W errorAST parse ofmdm/onboard.pyand all nine*/mdm/setup.pyfiles is clean on 3.9 and 3.14 (the\Acase failed before);py_compile+ pyflakes 3.4.0 clean on the changed files.Not verified here:
pwshis not installed on the authoring machine, soonboard.ps1has only had a brace/paren balance check; it is being exercised on a Windows VM directly from this branch. The key exchange has not been run against a live backend from this script (endpoint, params and header matchfetch_api_key_from_mdminclaude-code/hooks/mdm/setup.py).🤖 Generated with Claude Code
https://claude.ai/code/session_01NYCyXQWih1Ujk9ScSpjvrp
Greptile Summary
The PR makes the discovery key optional by resolving the device owner’s key from the administrator key and hardware serial, and corrects Windows onboarding output and exit-code propagation.
Confidence Score: 4/5
The PR is not yet safe to merge because malformed argument ordering can still bypass the required API-key validation and run onboarding with an option token as the credential.
_flag_valueaccepts the token following--api-keywithout checking whether it is another option, so an invocation such as--api-key --backend-url https://tenantproceeds into every setup step and the discovery exchange with a bogus credential instead of failing validation.Files Needing Attention: mdm/onboard.py, tests/test_mdm_onboard.py
Important Files Changed
Sequence Diagram
sequenceDiagram participant MDM as MDM administrator participant W as Windows wrapper participant O as onboard.py participant B as Backend participant T as Tool setup scripts participant D as Discovery MDM->>W: Start onboarding with admin key W->>O: Forward onboarding arguments loop Supported tools O->>T: Run setup with admin key T-->>O: Step result end O->>O: Read hardware serial O->>B: Exchange admin key + serial B-->>O: Device owner's key O->>D: Scan with owner's key D-->>O: Discovery result O-->>W: Combined exit status W-->>MDM: Preserve stdout and exit statusReviews (2): Last reviewed commit: "fix(mdm): resolve the discovery key from..." | Re-trigger Greptile
Context used: