Spec: Device DLP rollout kit
Status: APPROVED 2026-09-30 (user: “approved”). The live-device serial fix (§5, point 1) went into PR #447 first, at the user’s request. Builds on:
docs/specs/device-dlp-agent.md(PR #447). Planes: admin and data (kit generation, the same tenant routes as/v1/tenant/me/devices/*); CI (release pipeline); the laptop (agent fixes). Guard path: untouched.
1. Problem & outcome
Today an admin assembles six pieces by hand and pastes the same values into several of them:
| Piece | By hand today |
|---|---|
| Installer | A CI artifact: unsigned, no Ollama, expires in 90 days |
| Settings | Edit a profile template: Shield URL, tenant, fleet, pinned key, token |
| Root certificate (macOS) | A separate download from the portal |
| Proxy (PAC) | A second profile (macOS) or a script (Windows) |
| Browser extension | A third profile, edited with the extension id |
| Health check | Run verify on a laptop |
Outcome. In the portal, under Device DLP, an admin chooses a fleet and an MDM and clicks Download rollout kit. The zip holds everything with every value filled in. The observable success conditions:
- Jamf or Kandji (macOS): upload one profile and one
.pkg, scope both to a group. Laptops enroll with no user action and appear in the fleet view. - Intune (Windows): add one MSI with the install command from the kit, and one platform script. Laptops enroll and appear in the fleet view.
- Intune (macOS): the same two uploads as Jamf.
- Nothing typed by hand. No placeholder is left in any kit file (a test checks this). Following the kit’s README takes at most 3 steps per MDM, then “watch the fleet view”.
- Fleet health in the MDM itself. Jamf gets an extension attribute, Kandji
an audit script and Intune a detection script, each reporting
verify. - Installers come from a signed, versioned release that includes Ollama. A helper in the kit fetches the release and checks its signature.
Non-goals
- Pushing into MDM APIs. Shield will not hold Jamf, Kandji or Intune credentials. The admin uploads the kit’s files.
- Hardware attestation (Apple Managed Device Attestation, Intune compliance checks at enrollment). Recorded as the future fix in §5.
- Automatic agent updates. The admin ships a new version by uploading the
new
.pkg/.msi, as with any MDM-managed app. - Linux, unmanaged or personal devices.
- The signing identities themselves. The Apple Developer ID and the Windows code-signing certificate belong to Votal; this spec only uses them.
2. Plane & latency contract
| Component | Runs | On a guard path? |
|---|---|---|
Kit generation POST /v1/tenant/me/devices/rollout-kits |
admin and data plane (CPU) | No. Off hot path, no guarded-traffic impact. Pure templating plus one token write. |
| Kit list, inventory API | admin and data plane | No. |
| Release pipeline | GitHub Actions | No. |
| Agent fixes (wait for settings, serial rule) | laptop, and /v1/devices/enroll on the data plane |
No. Enrollment is not a guard path. |
3. Data model
Kit tokens reuse the enrollment token store (core/dlp/devices.py):
| Key | Change |
|---|---|
device_enroll:{tenant_id}:{sha256(secret)} |
Adds kind: "kit", kit_id, mdm |
device_enroll_used:{tenant_id}:{sha} |
Unchanged (atomic INCR) |
Kit tokens get their own limits, because they sit in MDM for the life of the rollout (new hires join months later):
| Limit | Kit tokens | Other tokens (unchanged) |
|---|---|---|
| Expiry | default 180 days, max 365 | max 90 days |
| Uses | default 5,000, max 100,000 | max 10,000 |
A new kit record (never holds the token):
| Key | Shape | TTL |
|---|---|---|
device_kit:{tenant_id}:{kit_id} |
{kit_id, fleet, mdm, platforms, include_proxy, extension_ids, agent_version, root_fingerprint, token_id, created_by, created_at, expires_at} |
the token’s expiry + 30 days |
A new, opt-in serial allow list:
| Key | Shape | TTL |
|---|---|---|
device_inventory:{tenant_id} |
SET of sha256(serial), from an admin-uploaded MDM export. Serials are hashed on upload; plain serials are never stored. |
none |
When the set is non-empty, enrollment requires the laptop’s serial_hash to be
in it.
Tenant scoping. Every key carries tenant_id, which comes from the
authenticated key. The kit’s token names its tenant (vde.<tenant>.<secret>)
and is checked against that tenant’s store only, as today.
4. API / interface
All tenant routes are on both planes, behind the registry write gate for
writes, with an admin audit record. Kit generation needs
SHIELD_RUNTIME_BUNDLE_PRIVATE_KEY, plus SHIELD_DEVICE_CA_MASTER_KEY when the
kit includes macOS.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/tenant/me/devices/rollout-kits |
Mint a kit token and return the kit as application/zip (X-Votal-Kit-Id header) |
| GET | /v1/tenant/me/devices/rollout-kits |
Kits so far (never the token): fleet, MDM, uses left, expiry, and whether stale |
| DELETE | /v1/tenant/me/devices/rollout-kits/{kit_id} |
Revoke the kit’s token. Enrolled laptops keep working. |
| PUT | /v1/tenant/me/devices/inventory |
Body: CSV or a JSON list of serials; replaces the allow list; returns the count |
| GET | /v1/tenant/me/devices/inventory |
The count and when it was uploaded (never the serials) |
| DELETE | /v1/tenant/me/devices/inventory |
Clear it: enrollment is no longer restricted |
Kit request body:
{"fleet": "sales", "mdm": "jamf", "platforms": ["macos"],
"include_proxy": true, "extension_ids": ["<32 letters>"],
"expires_in_days": 180, "uses": 5000, "revoke_previous": false}
mdmis one ofjamf,kandji,intune.platformsdefaults to["macos"]for Jamf and Kandji, and to["windows", "macos"]for Intune.extension_idsdefaults to the published Votal extension (SHIELD_BROWSER_EXTENSION_IDS).revoke_previousrevokes the previous kit tokens for the same fleet and MDM.
A kit is stale when the tenant root has been reissued since the kit was made, or the policy has an AI host its root does not cover. The kit list shows it, and the portal says “download a new kit”.
Kit contents. The token appears only in the files marked (token).
macOS, for Jamf, Kandji and Intune:
-
Votal-Device-Agent.mobileconfig(token): one profile with every payload:Payload Purpose ai.votal.device-agentSettings: Shield URL, tenant, fleet, pinned key, token, extension ids, CAModetenantcom.apple.security.rootThe tenant root com.apple.SystemConfigurationPAC http://127.0.0.1:47823/proxy.pac, fallback allowed. Only wheninclude_proxy.com.google.Chrome,com.microsoft.EdgeExtensionInstallForcelistcom.apple.servicemanagementManaged Login Items (macOS 13+): a rule for the Label ai.votal.device-agentand Votal’s Team ID, so users cannot turn the agent off and see no “background item added” prompt get-installer.sh: downloads the pinned version’s.pkgfrom the release, then checkspkgutil --check-signature(Votal’s Team ID) andspctl -a -t install(notarized). Prints the file to upload.- A health check:
- Jamf:
jamf-extension-attribute.sh, which runsverifyand prints<result>ok</result>or the failing checks. - Kandji:
kandji-audit.sh, with the same output. - Intune on macOS: a custom attribute shell script.
- Jamf:
README.md: three steps for this MDM, then what to watch in the portal.
Windows (Intune):
install-command.txt(token): themsiexec /i ... /qn SHIELDURL=...line for the Intune app’s install command.set-pac.ps1: only wheninclude_proxy.browser-extensions.ps1:ExtensionInstallForcelistfor Chrome and Edge. The README also shows the settings-catalog alternative.detect.ps1: an Intune detection and remediation script that runsverify.get-installer.ps1: downloads the.msi, then checks the Authenticode signer and the SHA-256 from the release’sSHA256SUMS.README.md.
All kits:
SECURITY.txt: the kit contains an enrollment token, where it appears, and how to revoke it.kit.json: kit id, fleet, MDM, agent version, root fingerprint. No token.
Release (CI). A tag device-agent-v<version> produces a GitHub Release
(the repo is public) with:
votal-device-agent-<v>.pkg: signed with Developer ID Installer, binaries with Developer ID Application, notarized and stapled.votal-device-agent-<v>.msi: Authenticode-signed.SHA256SUMS.
Both installers bundle Ollama at the version and SHA-256 pinned in
packages/votal-device-agent/packaging/ollama.lock.
Kits pin the agent version: SHIELD_DEVICE_AGENT_VERSION, defaulting to the
version in the package.
5. Security & backward compatibility
The token is visible once it is deployed. A kit embeds an enrollment token
in MDM configuration. MDM admins can see it, and so can local users on each
laptop: Windows HKLM\SOFTWARE\Policies is readable by Users, and the macOS
managed-preferences files are assumed readable (to confirm on an enrolled Mac).
So an employee can enroll a software “device”. That gets them:
| Obtained | Impact |
|---|---|
| The DLP bundle | Low: the same policy already on their laptop |
| A device key | Events attributed to a new device id: noise, visible in the fleet view |
| A 7-day intermediate CA, name-constrained to AI hosts | The real risk: from an on-path position (same network, ARP spoofing), intercepting a colleague’s AI traffic. Never any other site; macOS and curl enforce the constraints (verified in PR #447). |
Mitigations in this spec:
- No takeover of a live device (changes today’s behavior). Today an
enrollment with the same
serial_hashand fleet replaces the old identity. Because serial numbers are visible (About This Mac), an employee could then knock a colleague’s laptop off by claiming its serial.- Now, if that device sent a heartbeat in the last 24 h, the enrollment is
refused (409) and recorded as a
dlpevent with severity high. - A genuine reinstall on a silent device still replaces it, as today.
- Escape hatch:
SHIELD_DEVICE_REENROLL_LIVE=replacerestores the old behavior. Migration note in the admin guide: to reinstall a laptop within 24 h, revoke it in the portal first.
- Now, if that device sent a heartbeat in the last 24 h, the enrollment is
refused (409) and recorded as a
- Opt-in inventory allow list. Upload the MDM’s serial export; only those serials enroll. A software “device” then needs a real, currently unenrolled company serial, not any string.
- Visibility. The fleet view shows enrollments per kit per day, and kits with unusual enrollment counts. Revoking a kit is one click.
- Short, rotatable tokens. 180 days by default. Regenerating a kit mints a
new token;
revoke_previousretires the old one. - No automatic re-enrollment after revoke. A revoked laptop keeps
enforcing its last policy and reports
revoked; it does not use the MDM token to come back. This is today’s behavior, now written down and tested.
Residual risk, documented in the admin guide: without hardware attestation, an employee who controls a valid, unenrolled company serial can enroll a software device. The future fix is Apple Managed Device Attestation and the Intune compliance state at enrollment.
Other protections
- The kit zip is built in memory and never stored. Its token is shown by nothing else.
- Kit generation needs the registry write gate. It is audited
(
tenant_create_rollout_kit: kit id, fleet, MDM, token id; never the token). - Kit generation reissues the tenant root if the policy’s AI hosts are not covered (audited), because the kit is about to replace the profile anyway.
Backward compatibility. Existing tokens, devices, APIs and the manual
profiles in packaging/ are unchanged. The only default that changes is
live-device replacement (point 1), with its escape hatch.
6. Packaging & deploy
- New modules:
core/dlp/rollout_kit.py(stdlib only: zipfile, plistlib, json) and its templates incore/dlp/kit_templates/. Both ship to the admin image through the existingCOPY core/dlp/ core/dlp/. A guard test checks that the templates load in the admin image layout. - New routes: in
api/routes_devices.py, already copied to the admin image. - No new pip dependencies.
-
New env:
Variable Default SHIELD_DEVICE_AGENT_VERSIONthe package version SHIELD_BROWSER_EXTENSION_IDSempty SHIELD_DEVICE_REENROLL_LIVEreject - Existing env, now needed on the admin plane for kits:
SHIELD_RUNTIME_BUNDLE_PRIVATE_KEYandSHIELD_DEVICE_CA_MASTER_KEY. - Release workflow
.github/workflows/device-agent-release.yml, on tagdevice-agent-v*. Repository secrets:APPLE_APP_CERT_P12,APPLE_INSTALLER_CERT_P12,APPLE_CERT_PASSWORDNOTARY_KEY_ID,NOTARY_ISSUER,NOTARY_KEY_P8WINDOWS_SIGN_CERT_PFX,WINDOWS_SIGN_PASSWORD(or Azure Trusted Signing)
Without the secrets it publishes an unsigned pre-release, clearly labelled, so the pipeline can be tested before the certificates exist.
- Version source: one constant,
votal_device_agent/__init__.py __version__, replacing the two copies (__main__.VERSION,sync.AGENT_VERSION). - Images: rebuild the admin and data plane images (new routes and templates). Agent: a new release.
7. Failure modes & edge cases
| Case | Behavior |
|---|---|
macOS in the kit, but SHIELD_DEVICE_CA_MASTER_KEY unset |
503, naming the variable |
SHIELD_RUNTIME_BUNDLE_PRIVATE_KEY unset |
503 (laptops could not verify anything) |
| The policy has AI hosts the root does not cover | The root is reissued (audited) and the kit carries the new one; header X-Votal-Root-Reissued: 1 |
| No extension id | The extension payloads and script are left out; the README says the browser is still covered by the proxy |
include_proxy false (the customer already has a proxy profile; Apple allows one per Mac) |
The README gives the PAC lines to add to their PAC, and the kit leaves the proxy payload out |
| Token uses exhausted | Enrollment returns 401 “no uses left”; the kit list shows “exhausted, download a new kit” |
| Redis down while generating | 503; nothing is returned half-made (the token is written before the zip is built) |
| The release version is not published | get-installer fails, naming the version; the README links the release page |
| Unsigned release (no certificates yet) | The kit README says so. Intune for Mac rejects unsigned .pkg; Jamf and Kandji take it. get-installer.sh --allow-unsigned exists for pilots only. |
| Agent installed before the profile arrives | The service waits (checks every 30 s, logs once) instead of exiting and restarting every 10 s, as it does today |
| Reinstall within 24 h of the old agent’s last heartbeat | 409 until then, or the admin revokes the old device. The agent retries hourly and verify says why. |
| Profile removed from a laptop | The agent keeps its last settings and policy, and reports stale_bundle if it can no longer sync. Removal is visible in the MDM. |
| Huge inventory upload | Capped at 200,000 serials (about 13 MB of hashes in Redis); larger returns 413 |
| Two admins generate kits at once | Independent tokens; revoke_previous revokes only kits created before this one |
8. Test plan (Definition of Done)
Kit contents (per MDM and platform combination):
- Every expected file is present, and no
__PLACEHOLDER__remains anywhere. - The profile parses with plistlib. Payload types and UUIDs are unique. The root DER equals the current root. The PAC URL and the Login Items label are correct.
- The token appears only in the files marked (token) and nowhere in
kit.json,SECURITY.txtor the README. - Shell scripts pass
bash -n; PowerShell scripts are checked for syntax withpwshwhen available. - The customer READMEs have no em dashes.
API:
- Registry write gate; audit record without the token; the zip is not stored.
- The kit list never shows the token; stale detection; revoke and
revoke_previous. - Root auto-reissue; 503 without either key; uses and expiry limits for kit tokens against other tokens.
Serial rule:
- A live duplicate returns 409 and records a high-severity event.
- A duplicate silent for 24 h or more is replaced.
- The escape hatch restores the old behavior.
Inventory:
- Hashed on upload; allow and deny at enrollment; cleared means unrestricted; the 413 cap.
Agent:
- It waits for settings instead of exiting.
- It does not re-enroll after revoke.
- It retries after a 409.
Release workflow:
- The workflow YAML parses and names the secrets above.
- Without secrets, the path is unsigned and labelled pre-release.
ollama.lockis used and its checksum enforced.
Suite and CI:
- Guards:
Dockerfile.adminand the template files; the full suite green in a clean venv; the CIpytestgate.
Acceptance on real MDMs (manual, recorded in the PR):
- One Mac through Jamf or Kandji and one Windows laptop through Intune, following only the kit README.
- Each shows in the fleet view,
verifyis ok, and a password prompt to ChatGPT is blocked.
Tasks (one branch, feat/device-rollout-kit, after PR #447 merges; one commit per task)
| # | Task | Size |
|---|---|---|
| 1 | Unattended rollout fixes: the agent waits for settings; kit token kind and limits; a single version constant. (The live-device serial rule and its escape hatch shipped in PR #447 before merge.) | S |
| 2 | Kit generator (core/dlp/rollout_kit.py and templates): the macOS profile with all payloads, scripts and READMEs for Jamf, Kandji and Intune, and the Windows Intune kit; pure, tested without the API |
M |
| 3 | Kit API and portal: generate, list, revoke; stale detection; root auto-reissue; the “Roll out” card on the Device DLP page; tests and a browser check | M |
| 4 | Opt-in inventory allow list: API, enrollment check, portal upload | S |
| 5 | Release pipeline: ollama.lock, the tagged release workflow (signed when secrets exist, unsigned pre-release otherwise), SHA256SUMS, and the signature checks in the get-installer scripts |
M |
| 6 | Admin guide rewritten around the kit, plus the acceptance checklist | S |
What is needed from Votal (not code)
- An Apple Developer ID (Application and Installer certificates) and a notarization API key: for task 5 and for Intune on Mac.
- A Windows code-signing certificate, or an Azure Trusted Signing account.
- The published Chrome Web Store id of the extension, for
SHIELD_BROWSER_EXTENSION_IDS. - Production keys:
SHIELD_RUNTIME_BUNDLE_PRIVATE_KEYandSHIELD_DEVICE_CA_MASTER_KEYset on production (both planes). - For acceptance: one test Mac in Jamf or Kandji, and one Windows laptop in Intune.
Tasks 1 to 4 and 6 need none of these. Task 5 runs unsigned until items 1 and 2 exist.