Skip to content

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

Open
vigneshsubbiah16 wants to merge 3 commits into
mainfrom
fix/mdm-onboard-discovery-key-optional
Open

vigneshsubbiah16 wants to merge 3 commits into
mainfrom
fix/mdm-onboard-discovery-key-optional

Conversation

@vigneshsubbiah16

@vigneshsubbiah16 vigneshsubbiah16 commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

Regression

unbound-fe #1999 (WEB-5597, in prod since 2026-09-01) dropped -DiscoveryKey / --discovery-key from the MDM onboard commands the dashboard Configure page generates, because the backend's discovery auth (_validate_discovery_auth in ai-gateway-data) now accepts application/admin keys.

The wrappers served at https://getunbound.ai/setup/mdm/windows/onboard (mdm/onboard.ps1) and https://getunbound.ai/setup/mdm/onboard (mdm/onboard.py) were not updated and still hard-required the key:

-DiscoveryKey is required. Usage: ...

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.py lines 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 in onboard.py.

Fix

mdm/onboard.py

  • --discovery-key is optional. When absent, run_discovery_step resolves the device owner's key:
    1. get_device_serial() reads the hardware serial live: macOS ioreg -rd1 -c IOPlatformExpertDevice → IOPlatformSerialNumber; Linux dmidecode -s system-serial-number then /sys/class/dmi/id/product_serial; Windows the same chain as get_device_identifier() in claude-code/hooks/mdm/setup.py (Win32_BIOS serial → MachineGuid → hostname), copied rather than imported since the script is standalone.
    2. fetch_owner_key() → GET {backend}/api/v1/automations/mdm/get_application_api_key/?serial_number=<serial>&app_type=default with Authorization: Bearer <admin key>, stdlib urllib, 20 s timeout, one retry.
    3. Scans with resp["api_key"] and prints [Discovery] scanning with the device owner's key (serial <serial>) (keys are never printed).
  • Precedence: explicit --discovery-key (unchanged, deprecated) > owner key from the exchange > Discovery step fails with the cause named (no serial / HTTP error / no api_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-key is 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.
  • Per-tool MDM args are untouched; --discovery-key is still never forwarded to them.

mdm/onboard.ps1

  • The -DiscoveryKey is required gate is removed; -ApiKey stays required. --discovery-key is only forwarded when -DiscoveryKey was actually passed; onboard.py owns the resolution.
  • Header docs, .EXAMPLEs and the -ApiKey is required usage string now point at https://getunbound.ai/setup/mdm/windows/onboard. The URL they used to print, https://getunbound.ai/setup/mdm/onboard.ps1, returns HTTP 404.
  • Exit code / swallowed stdout: $exitCode = Main captured Main's entire success stream, i.e. every line onboard.py and the per-tool setup.py scripts print to stdout, and exit on the resulting Object[] returned 0. Measured on a Windows VM: a Main whose native 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. 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. Main is now invoked bare so stdout flows to the host; the exit code is stored in $script:pythonExitCode (defaulted to 1 before Main so an early stop can never leak a 0) and used by the final exit. Exit-WithError paths and the self-destruct block are unchanged.

mdm/README.md – drops the "separate key required" wording and the -DiscoveryKey / --discovery-key lines from every example; documents the serial exchange and the deprecated override.

claude-code/hooks/mdm/setup.py – _claude_desktop_support_dirs docstring contains <home>\AppData\Roaming in a non-raw string, which Python 3.12+ reports as SyntaxWarning: invalid escape sequence '\A' on stderr at compile time. Made it a raw docstring. The other eight */mdm/setup.py files were checked the same way and are clean.

Testing

tests/test_mdm_onboard.py drives onboard.main() through sys.argv with the admin check, downloads, subprocesses and serial stubbed and urllib.request.urlopen scripted:

  • exchange happy path: discovery runs with the owner key; request URL is …/get_application_api_key/?serial_number=SER123&app_type=default, header Authorization: Bearer ADMIN, timeout 20; per-tool args are exactly --api-key ADMIN; the info line is printed and no key appears in stdout
  • explicit --discovery-key wins, skips the exchange, and is never forwarded to per-tool scripts
  • --backend-url is used for both the exchange URL and discovery
  • exchange failure (URLError ×2, HTTP 404 ×2, body without api_key, non-JSON body) → Discovery counted as failed, exit 1, tools still ran, retry count as expected, cause named on stderr
  • missing serial → same, with no exchange attempted
  • missing api key still errors (--discovery-key only / --api-key bare / empty) and nothing runs
  • --clear needs no keys and skips discovery

Results (CI command python -m pytest -q, pytest 9.0.2):

tests/test_mdm_onboard.py:  12 passed
full suite:                 2562 passed, 40 skipped, 219 subtests passed   (baseline on main: 2550 passed, 40 skipped)

Also: get_device_serial() returns a real serial on a Mac under the system Python 3.9; python -W error AST parse of mdm/onboard.py and all nine */mdm/setup.py files is clean on 3.9 and 3.14 (the \A case failed before); py_compile + pyflakes 3.4.0 clean on the changed files.

Not verified here: pwsh is not installed on the authoring machine, so onboard.ps1 has 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 match fetch_api_key_from_mdm in claude-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.

  • Adds cross-platform serial discovery and a retried owner-key exchange.
  • Updates the Python and PowerShell onboarding contracts and documentation.
  • Adds tests for successful exchanges, exchange failures, explicit discovery-key precedence, and missing key values.

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_value accepts the token following --api-key without checking whether it is another option, so an invocation such as --api-key --backend-url https://tenant proceeds 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

Filename Overview
mdm/onboard.py Adds device-serial resolution and owner-key exchange while retaining the existing onboarding sequence and failure aggregation.
mdm/onboard.ps1 Makes the discovery-key argument optional and propagates Python stdout and exit status directly.
tests/test_mdm_onboard.py Covers owner-key exchange behavior, explicit-key precedence, failure handling, URL overrides, and basic missing-key cases.
mdm/README.md Documents automatic owner-key resolution and updates platform onboarding examples.
claude-code/hooks/mdm/setup.py Converts one Windows-path docstring to a raw string to avoid invalid-escape warnings.

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 status
Loading

Reviews (2): Last reviewed commit: "fix(mdm): resolve the discovery key from..." | Re-trigger Greptile

Context used:

…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
@vigneshsubbiah16
vigneshsubbiah16 marked this pull request as ready for review September 3, 2026 01:35
@vigneshsubbiah16
vigneshsubbiah16 requested a review from a team September 3, 2026 01:35
@cursor

cursor Bot commented Sep 3, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot 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 vigneshsubbiah16 changed the title fix(mdm): make the discovery key optional in onboard.ps1/onboard.py (fall back to the admin key) fix(mdm): make the discovery key optional, fix the 404 usage URL and the swallowed exit code in onboard.ps1/onboard.py Sep 3, 2026

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ 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

Comment thread mdm/onboard.py
i = args.index(flag)
except ValueError:
return None
return args[i + 1] if i + 1 < len(args) else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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

@vigneshsubbiah16
vigneshsubbiah16 marked this pull request as draft September 3, 2026 01:43
@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator Author

Verified on a fresh Azure Windows Server 2022 VM, run as SYSTEM via az vm run-command (same context as an Intune remediation), Python 3.12 all-users, Node 20, Claude Code 2.1.197 installed under a user profile.

Run Wrapper Command Result
A main .\onboard.ps1 -ApiKey <ADMIN> ... -Backfill (as the Configure page emits) exit 1, -DiscoveryKey is required (the Xome failure)
capture main Main whose python prints one line and exits 3 cmd-level exit 0, $exitCode = Object[] [py-stdout-line 3]
D 6426d25 no -DiscoveryKey parses, exit 0, discovery found 1 tool
E 7551a37 no -DiscoveryKey exit 0; log now has all five Setup Complete!, the step banners and MDM onboarding complete (278 lines vs 28 on main)
F 7551a37 no -DiscoveryKey, -BackendUrl https://backend.invalid.getunbound.ai exit 1, MDM onboarding finished with 5 failure(s)

[scriptblock]::Create parse check on 7551a37: OK.

…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
@vigneshsubbiah16 vigneshsubbiah16 changed the title fix(mdm): make the discovery key optional, fix the 404 usage URL and the swallowed exit code in onboard.ps1/onboard.py 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 Sep 3, 2026
@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator Author

Attribution verified end to end on the production read replica (org 5, Unbound's own admin key), two fresh Win2022 VMs as SYSTEM:

VM / device Wrapper Discovery key used device_discovery.user_id → ai_tool_report_inboxes.attributed_user
xome-win1 0000-0016-9070-3993-3057-4186-06 7551a37 (admin key passed as -DiscoveryKey) admin key vis@unboundsecurity.ai (the key owner) vis@
xome-win2 0000-0003-9009-4946-6433-9320-57 e8f6433, no -DiscoveryKey owner key from the serial exchange 0000-0003-…-57@unboundsecurity.ai (PLACEHOLDER for that serial) same placeholder

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):

[Discovery] scanning with the device owner's key (serial 0000-0003-9009-4946-6433-9320-57)
2026-09-03 02:18:15 INFO Detection complete: 1 unique tool(s) found across all users
2026-09-03 02:18:18 INFO ✓ Scan completed event sent successfully
✅ MDM onboarding complete: Claude Code, Cursor, Codex, GitHub Copilot, Augment, Discovery

[scriptblock]::Create parse of the e8f6433 onboard.ps1: OK. (The wrapper still fetches onboard.py from main, so for this run the URL was pointed at the commit; nothing else changed.)

@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator Author

Third data point, same replica, same org 5, third fresh VM (0000-0009-5271-8032-4798-0231-87): today's main wrapper with the original two-key form (admin key + the org's real discovery key) → ai_tool_report_inboxes.attributed_user_id = NULL (discovery-key path) and device_discovery.user_id = the placeholder for that serial via the MDM serial lookup. So the pre-#1999 behaviour and this PR's exchange agree on attribution; only "admin key as discovery key" diverges. Customer interim guidance until this merges: keep passing the real org discovery key (still valid, still returned by /api/v1/onboarding/setup-data/ for admins; the UI just stopped rendering it).

@vigneshsubbiah16
vigneshsubbiah16 marked this pull request as ready for review September 3, 2026 03:27
@cursor

cursor Bot commented Sep 3, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot 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 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ 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_stdout invokes ioreg, dmidecode, and powershell by bare name while running as SYSTEM (Windows Intune) or root (sudo); a writable early PATH entry 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/dmidecode with /usr/bin/dmidecode fallback); skip the source if the binary is missing rather than falling back to PATH.
  • Reviewers: Claude

🔴 HIGH — --api-key accepts the next flag token as the key value

  • File: mdm/onboard.py:502
  • Impact: _flag_value returns args[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 required error.
  • Fix: Return None when 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-url can exfiltrate the fleet admin key over cleartext http or to an attacker-controlled host, with little local trace after wrapper self-destruct.
  • Fix: Parse with urllib.parse.urlparse and reject non-https schemes (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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant