Behavry Warden Desktop Agent
Warden is a local HTTPS proxy that runs as a system service on Windows and macOS. It inspects outbound traffic to known AI provider domains, applies DLP scanning and policy enforcement, and reports telemetry to your Behavry backend. Unlike the browser extension, it covers every AI application on the endpoint — desktop apps, CLI tools, and IDEs — not just browser tabs.
This page covers installing it, upgrading it, and the operational details you need when something looks wrong.
Warden installs a trusted root certificate and configures the system proxy. Both are privileged operations, so every install and upgrade command below must run elevated.
Before you start
You need two things:
- A one-time enrollment token, generated from the Provisioning workspace. See Provisioning.
- Your backend URL — the Behavry instance this device should report to.
--backend is always requiredWarden ships with no backend URL compiled in, deliberately. A binary with a default instance baked into it would silently bind any enrollment that omitted --backend to that instance, and a wrong-instance enrollment is close to invisible afterwards. So --backend must be passed at enrollment, and the daemon refuses to start without one.
Fresh install
Downloads are served from downloads.behavry.ai, named for the version, platform and build they came from — for example behavry-warden-v0.5.6-macos-universal-cc330fa or behavry-warden-v0.5.6-windows-amd64-cc330fa.exe. The dashboard's download button always hands out the current one. macOS is a single universal binary covering Apple Silicon and Intel.
The instructions below assume you have renamed it to warden.exe (Windows) or warden (macOS and Linux) after downloading. That rename is a convention rather than a requirement, but it keeps service registration and later upgrades predictable.
From v0.5.5 the menu bar / notification-area indicator ships inside the daemon and is written out during setup. There is no second download and no file to place. Earlier versions required both, which is the main thing to know when upgrading an older device — see Upgrading.
Windows
On Windows you can run the downloaded file where it sits: --install-service copies it into C:\Program Files\Behavry\Warden\warden.exe and registers the service against that copy, so the download itself is disposable. Renaming it first only shortens the commands below.
Move-Item .\behavry-warden-v0.5.6-windows-amd64-cc330fa.exe .\warden.exe
scIn PowerShell, sc is an alias for the Set-Content cmdlet, not the Windows service controller. Use sc.exe explicitly. In Command Prompt, plain sc is fine.
Open PowerShell as Administrator:
.\warden.exe --enroll YOUR-ENROLLMENT-TOKEN --backend https://your-instance.behavry.ai
That single command enrolls the device, displays the privacy notice for acknowledgment, generates and installs the device CA into the Windows Root store, and writes the system proxy PAC setting.
Then register the service:
.\warden.exe --install-service
This creates the BehavryWarden service with start= auto so it survives reboots, and starts it immediately — you do not need a separate sc.exe start.
Verify:
.\warden.exe --version
sc.exe query BehavryWarden
macOS
macOS ships as one universal binary covering both Apple Silicon and Intel. Unlike Windows, launchd registers whatever path the executable sits at, so put it where it will stay — /usr/local/behavry/ is the convention — rather than leaving it in Downloads:
sudo mkdir -p /usr/local/behavry
sudo install -o root -g wheel -m 755 ./behavry-warden-v0.5.6-macos-universal-cc330fa /usr/local/behavry/warden
sudo xattr -d com.apple.quarantine /usr/local/behavry/warden 2>/dev/null || true
cd /usr/local/behavry
The xattr line clears the quarantine flag macOS applies to browser downloads. Builds fetched with curl or pushed by MDM are not quarantined and do not need it. Then enroll:
sudo ./warden --enroll YOUR-ENROLLMENT-TOKEN --backend https://your-instance.behavry.ai
Then register the launchd service, which starts at boot and restarts on failure:
sudo ./warden --install-service
Verify:
./warden --version
Upgrading an installed device
Whether an upgrade needs a new enrollment token depends on one thing: is the device staying on the same backend?
What existing deployments need
Upgrading the daemon alone is not always sufficient. Find the version a device is on with warden --version, then apply everything at or below it:
| Device is on | What it needs beyond replacing the binary |
|---|---|
| Behavry Intercept / Sentinel (pre-rename) | Full uninstall and clean install. The binary path, service name and config directory all changed, so nothing upgrades in place. |
| Any version below v0.5.5 | Run --install-tray after upgrading. The indicator now ships inside the daemon; without this the device keeps its old separately-installed indicator, or has none. |
| Any version below v0.5.4 (Windows) | Nothing to run — but note the exposure map was blank on Windows before this. It populates on the next discovery pass, or immediately with --discover-once. |
| Any version below v0.5.3 | Nothing to run. If the device is paused on a privacy notice, it can now be acknowledged from the indicator rather than a terminal. |
None of these require re-enrolling, and none invalidate the device token.
Warden loads warden.yaml directly, and a device with a config keeps its existing backend_url on upgrade. That is what makes an in-place upgrade safe — and it is also why swapping the binary alone can never repoint a device at a different backend.
Staying on the same backend
This is the normal case. No new enrollment token is required. The existing device token remains valid and warden.yaml is preserved.
The two platforms differ in one way that matters:
- Windows —
--install-servicestages the new binary intoC:\Program Files\Behavry\Warden\itself, stopping the running service first. Run it from wherever you downloaded it; you do not need to place the file or stop the service by hand. - macOS — launchd points at wherever the binary sits, so replace it at its existing path (typically
/usr/local/behavry/warden) and then restart. Running--install-servicefrom a different location repoints the daemon at that location.
On Windows:
.\behavry-warden-v0.5.6-windows-amd64-cc330fa.exe --install-service
& "C:\Program Files\Behavry\Warden\warden.exe" --install-tray
On macOS, replacing the binary in place:
sudo install -o root -g wheel -m 755 ./behavry-warden-v0.5.6-macos-universal-cc330fa /usr/local/behavry/warden
sudo launchctl kickstart -k system/ai.behavry.warden
sudo /usr/local/behavry/warden --install-tray
Confirm the new build is live on either platform:
./warden --version
On macOS the indicator starts immediately in the signed-in user's session. On Windows it is registered to start at the next sign-in and will not appear until then — launch C:\Program Files\Behavry\Warden\warden-tray.exe to see it sooner. Windows 11 also hides new tray icons behind the ^ chevron by default.
Moving to a different backend
Moving a device between instances does require a fresh enrollment token issued by the destination instance. The stored device token was issued by the original backend and will not authenticate against a new one, so editing backend_url by hand is not sufficient — it produces a device that fails every call.
Stop the service, replace the binary, then move the existing config aside:
sudo mv /Library/Behavry/Warden/warden.yaml /Library/Behavry/Warden/warden.yaml.bak
On Windows the equivalent path is %PROGRAMDATA%\Behavry\Warden\warden.yaml.
Leave ca.crt and ca.key in place. Keeping them means the already-trusted root certificate stays valid, so you skip reinstalling and re-trusting a new CA. Deleting the whole directory forces a full CA reinstall on every upgraded machine.
Then enroll against the new instance and restart the service:
sudo ./warden --enroll YOUR-NEW-TOKEN --backend https://your-new-instance.behavry.ai
sudo ./warden --install-service
Identity on upgraded devices
The person a device is attributed to is resolved at enrollment and stored as user_identifier. Devices enrolled by older builds have no such value, and Warden falls back to the OS console user for each event rather than failing.
Attribution keeps working after an upgrade — it simply uses the console user instead of the backend-resolved identity. If you need the authoritative backend identity on an existing device, re-enroll it. Upgrading alone will not backfill the field.
Configuration
Config lives alongside the CA material in the platform data directory:
| Platform | Path |
|---|---|
| Windows | %PROGRAMDATA%\Behavry\Warden\ |
| macOS | /Library/Behavry/Warden/ |
| Linux | ~/.behavry/warden/ |
The file is YAML. Most deployments never need to edit it — enrollment writes it.
listen_addr: 127.0.0.1:8877
pac_addr: 127.0.0.1:8878
receipt_addr: 127.0.0.1:8879
backend_url: https://your-instance.behavry.ai
device_id: <assigned at enrollment>
device_token: <assigned at enrollment>
user_identifier: <resolved at enrollment>
extra_domains: []
discovery_shell_history: false
Ports
Warden binds three loopback ports. They must be distinct — the daemon logs a bind failure and continues rather than crashing, so a collision silently disables whichever server loses the race.
| Port | Purpose |
|---|---|
127.0.0.1:8877 | HTTPS inspection proxy |
127.0.0.1:8878 | PAC file server |
127.0.0.1:8879 | Employee privacy view |
The privacy view is a local, loopback-only page where the person using the device can see exactly what is being reported about them. Browse to http://127.0.0.1:8879/.
Earlier builds defaulted the privacy view to the same port as the PAC server, which meant it silently never started. Current builds repair this automatically on upgrade — no manual edit required.
Managed config (managed.json)
Administrators supply values Warden should not resolve for itself by dropping a
managed.json beside warden.yaml in the data directory:
{
"user_identifier": "person@your-company.com",
"uem_device_id": "<the device's own ID in your UEM>"
}
Both keys are optional and independent.
user_identifier — seat identity
Warden reports which person a device belongs to, so that one human running both the browser extension and a Warden counts as one billable seat rather than two. The two collapse into one only when they report the same identifier, and the extension reports an email address — so the value that matters is a corporate email.
uem_device_id — exact UEM matching
Added in v0.6.0. Intune and Workspace ONE can each substitute a device's own ID into a managed-config value per device at deploy time, which makes the join between a Warden and your UEM inventory exact rather than inferred. It is the single highest-value thing an administrator can do for coverage accuracy — see UEM connections.
This is a separate file from warden.yaml on purpose: Warden rewrites
warden.yaml on enrollment and on remediation, so a value placed there would
eventually be overwritten. managed.json is only ever read, never written, which
makes it safe for MDM to own.
Warden re-reads it periodically, so correcting the file fixes a device's identity on its next check-in — no restart, no re-enrollment.
If no managed file is present, Warden falls back to an email exposed by the OS account where the platform has one, and then to the OS username of the person currently signed in. Only the first is email-shaped, so a device relying on the username fallback appears as its own seat. That over-counts rather than merging two people, and the operator dashboard shows which devices are in that state so it can be corrected before invoicing.
The signed-in user is resolved fresh on every check-in rather than recorded once at install. Warden runs as a system daemon, so a value captured at install time would name whoever ran the installer — and on a shared machine that would silently attribute everyone's activity to that one person. Resolving it live means each real person becomes their own row, so two people genuinely sharing a machine count as two seats, which is correct. The identity your backend resolved at enrollment is used only as a last resort, for a device where nobody is signed in at all, such as one provisioned by a script.
A device that cannot resolve an identity omits the field entirely and remains fully functional. It simply counts as its own seat.
Discovery is governed centrally
AI-exposure discovery is controlled by your tenant's server-side setting, delivered on the policy channel. The local discovery_enabled key in warden.yaml is legacy and no longer gates the discovery loop.
This matters when upgrading: a device carrying a stale local discovery_enabled: false will begin scanning once upgraded, if the tenant has discovery enabled centrally. That is deliberate — a local flag set once was leaving devices dark long after the fleet policy changed — but plan for it.
Config-file discovery reads declared destinations only, never content. More invasive sources keep their own gates. Shell-history scanning, which reports which AI CLI tools have been run (command names only, never arguments), requires both the tenant's server-side grant and local discovery_shell_history: true. Setting the local flag alone grants nothing.
Provider coverage is governed centrally
Which AI providers a Warden intercepts is delivered on the policy channel, not compiled into the binary alone. A provider added in the dashboard is routed and inspected fleet-wide on the next policy refresh — nothing installed, nothing restarted, no re-enrollment. See AI provider coverage.
An empty list, an unreachable backend, and a response that lost the field all resolve to "the providers this binary shipped with" — never to none. Narrowing coverage must not be something a server outage can do by accident.
A provider pushed as a chat UI is routed but stays undecrypted until the tenant's web-UI interception grant is on, exactly like one the agent shipped with. Adding a provider is not a way to decrypt someone's browser session without the authorization that decision requires.
extra_domains never reached the PAC before thisThe local extra_domains key was passed to the interception gate but not into
the generated PAC, so a host added that way was intercepted only if something
else happened to route it to the proxy. On any device relying on the PAC — which
is all of them — that setting had never done anything. It works as documented
from the current build. Prefer central provider management over this key anyway.
Hardware identifiers (v0.6.0)
Warden reports hardware identifiers so a device can be matched to the same physical machine in your Intune, Workspace ONE, or Google inventory. This is what turns "41 Wardens installed" into "1,240 endpoints, 340 covered, here are the 900 that are not."
v0.6.0 is not released. The current download served by downloads.behavry.ai and
the dashboard button is v0.5.6. This section describes what arrives with
v0.6.0 so you can plan the managed-config work ahead of it.
Up to four identifiers are reported, each optional and independent — a device sends whichever it can read:
| Identifier | Source |
|---|---|
| Serial number | macOS: IOKit. Windows: WMI (Win32_BIOS) |
| Hardware UUID | macOS: IOKit. Windows: Win32_ComputerSystemProduct, falling back to the Cryptography MachineGuid |
| Entra device ID | Windows only, parsed from dsregcmd /status, when the machine is Entra- or hybrid-joined |
| UEM device ID | Supplied through managed.json |
The macOS values are the same ones system_profiler SPHardwareDataType shows.
All of it is readable by the privileged service Warden already runs as — on either platform, on a clean install or an upgrade.
Why hostname was not enough
Hostname collides, changes when a machine is renamed, and disagrees with what the UEM recorded at enrollment. A match on it is a guess, and a guess is not something that should be allowed to restrict anyone's access — which is why a hostname-only match is refused for device posture.
Placeholder identifiers are refused, not reported
OEMs, hypervisors, and imaging tools ship machines with values like To Be Filled By O.E.M., System Serial Number, and all-zero UUIDs, identical across
thousands of machines. Sending one is worse than sending nothing, because it
looks legitimate: every machine reporting it would collapse into a single device
and the coverage number would quietly become fiction. Warden omits the key
instead.
Hypervisor guests are covered too — VMware, VirtualBox, Parallels, and Hyper-V hand every guest cloned from one template the same structurally valid serial, so a guest reports its UUID and no serial at all.
Upgrading
Nothing needs re-enrolling. Devices already in the field backfill on their first
heartbeat after upgrading, and the values are re-sent on every heartbeat after
that — so a machine that is Entra-joined later, or whose administrator wires up
uem_device_id afterward, corrects itself on the next poll.
v0.5.x and v0.6.0 run side by side indefinitely. Coverage accuracy improves as the fleet upgrades rather than switching on in one step.
What the person on the device sees
These appear on the employee receipt in full. warden --privacy and the
local privacy page show a "How this device is identified" section listing the
hostname and whichever identifiers the server recorded.
The receipt's whole claim is that it is the complete set of what the sensor reported, so a new class of reported data that did not appear there would have made that claim false. The values are shown unmasked deliberately: the person reading them is holding the machine they describe and can print the same values out of their own operating system, so a redacted serial would withhold nothing from anyone and would cost the reader the only thing the receipt is for.
They are read back from the server rather than re-resolved locally, so an identifier the server rejected as a placeholder correctly shows as absent rather than as something that was reported.
These are asset identifiers for the machine — the same class of data as the hostname Warden already reported. They are never logged and never included in crash reports.
It enrolls, heartbeats, and enforces exactly as before, and reports no hardware identifiers. The UEMs Behavry integrates with inventory macOS and Windows.
Privacy notices
Enrollment displays your organization's privacy notice and records an acknowledgment. How that acknowledgment was obtained is recorded with it, because an MDM push and a person actually reading the notice are not the same evidence:
- Interactive — the default. A person on the device reads the notice and confirms it.
- Administrative —
--attest-notice-delivered. Your organization attests it delivered the notice by other means, such as a handbook or notice at hire. The record is attributed to the organization, not to the person on the device. Use this for silent and MDM installs.
When your organization updates its privacy notice, the backend pauses that device's reporting until the new notice is acknowledged. A device that has gone unexpectedly quiet after a policy change is usually waiting on this.
From v0.5.3 the person on the device can do this themselves: the status indicator offers Review and acknowledge privacy notice… whenever one is outstanding, which opens the notice locally with a button to acknowledge it. That is usually the fastest route, since the people who notice the paused indicator are rarely the people holding an administrator prompt.
It can also be done from a terminal, which is the path for MDM and headless devices:
./warden --ack-disclosure
Type the full word yes at the prompt — anything else is treated as declining. It reads from the terminal, so it cannot be run from a script; use --attest-notice-delivered for that.
Activity captured while a device is paused is discarded, not queued. Collecting it and uploading it once the notice is acknowledged would mean back-filling a period the notice did not cover, so the events are dropped and only the fact of the gap is recorded.
Command reference
| Flag | Purpose |
|---|---|
--enroll <token> | Enroll with a one-time token. Also runs --setup. |
--backend <url> | Backend URL, used with --enroll. |
--config <path> | Path to warden.yaml. Defaults to the platform data dir. |
--version | Print the Warden version and exit. |
--status | Print a one-line sensor status and exit. |
--privacy | Show what this device reports, then exit. |
--setup | Install the CA and configure the PAC, then exit. |
--install-ca | Install the device CA into the system trust store only. |
--configure-pac | Write the PAC URL to system proxy settings only. |
--pac | Print PAC file content to stdout. |
--install-service | Register Warden as a system service and start it. On Windows this also stages the binary into Program Files. |
--uninstall-service | Remove the Warden system service. |
--install-tray | Write out the bundled status indicator and start it at login. |
--uninstall-tray | Stop starting the status indicator at login. |
--no-tray | Used with --setup: skip installing the indicator. |
--ack-disclosure | Review and acknowledge an updated privacy notice. |
--attest-notice-delivered | Record organizational delivery of the notice, for MDM installs. |
--discover-once | Run a single AI-exposure discovery pass and exit. |
--remediate-once | Apply pending config remediations and exit. |
--accept-disclosure is deprecatedIt remains as an alias for --attest-notice-delivered. Update any provisioning scripts that still use it.
Seeing what the fleet is doing
Wardens report a great deal about every intercepted AI call — provider, domain, model, message and tool counts, input and output tokens, finish reason, latency, the allow/warn/block decision, the calling application, and any DLP findings.
The only Warden activity visible anywhere was the subset that tripped a DLP rule. A device inspecting AI calls all day and finding nothing worth flagging produced no surface at all — which is indistinguishable from a device that has stopped reporting.
GET /api/v1/wardens/activity (aliased at /api/v1/desktop/activity) is
admin-only and tenant-scoped, over a window of 1h, 24h, 7d, or 30d. It
returns:
- Exact totals — events, allow/warn/block counts, events carrying findings, summed tokens, devices reporting, and the last event timestamp
- A bucketed time series
- Breakdowns by application, provider, and model
- A per-device rollup, joined to hostname and platform
Pass device_id to scope every figure to one device for a drill-down.
It is deliberately one call rather than five: a card whose total came from one request and whose breakdown came from another, seconds later, shows a breakdown that does not sum to its own total while traffic is flowing.
Three details worth knowing when reading the numbers:
| Behaviour | Why |
|---|---|
| Health notices are not counted as traffic | A device's own "my interception is degraded" event lives in the same table. Counting it as an AI call would inflate exactly the number you use to judge coverage. |
| Blocks come from the decision, not from findings | A request can be refused for a reason that produces no DLP finding. Counting blocks by findings would file that as an ordinary allowed call. |
A missing application stays null | Returned as null rather than folded into an "unknown" bucket. That separates "this app made the call" from "no attribution available" — the honest state for agents older than v0.6.0, and for a process that exited before it could be named. |
Troubleshooting
The device never appears in the fleet table. Check backend_url in warden.yaml first. If the device was enrolled without an explicit --backend, it used the build's compiled-in default, which may not be your instance.
Every device reports the same version. Release binaries are stamped at build time. A binary built without that stamp reports a development version, which makes an upgraded machine indistinguishable from one never touched. Confirm with --version on the device itself rather than trusting the fleet table.
Reporting stopped after a policy change. The organization's privacy notice was likely updated, which pauses the device. Run --ack-disclosure on it.
The privacy view will not load. Confirm receipt_addr does not collide with pac_addr or listen_addr. The bind failure is logged but non-fatal, so the daemon looks healthy while the view is absent.
Unsigned binary warnings. The release pipeline now signs and notarizes every artifact it publishes — the macOS daemon and indicator, and the Windows binaries. Signing runs only where the corresponding credentials are configured and is a no-op without them, so whether a given download is signed depends on the release it came from.
The currently published v0.5.6 download predates that change and is unsigned. Until a signed release supersedes it, a build downloaded through a browser trips Gatekeeper on macOS and SmartScreen on Windows.
| Platform | Status |
|---|---|
| macOS | Developer ID signing and notarization wired; certificate enrollment in progress |
| Windows | Authenticode signing wired through a pinned, checksum-verified signer; the certificate authority is not yet chosen |
A bare executable has nowhere to carry a notarization ticket, so the daemon is notarized without being stapled — Gatekeeper checks it online instead. Closing that gap means shipping a .pkg.
Gatekeeper and SmartScreen warnings affect browser downloads only, because both mechanisms key off the quarantine flag the browser attaches. Builds fetched with curl or Invoke-WebRequest, or pushed by MDM, are unaffected — which is the recommended path for fleet deployment regardless. On macOS, clear the flag by hand with sudo xattr -d com.apple.quarantine /usr/local/behavry/warden.
sc.exe stop reports error 1060, "the specified service does not exist". The device was enrolled but the service was never registered — this happens when Warden was run interactively rather than installed, which reports to the backend for as long as the process lives and then goes quiet. Confirm nothing is running and nothing will relaunch it:
Get-Service | Where-Object { $_.Name -match 'behavry|warden' }
Get-Process | Where-Object { $_.Name -match 'warden' }
Get-NetTCPConnection -LocalPort 8877,8878,8879 -State Listen -ErrorAction SilentlyContinue
Get-CimInstance Win32_StartupCommand | Where-Object { $_.Command -match 'warden|behavry' }
If all four are empty, run --setup and --install-service as a normal install. The existing warden.yaml preserves the enrollment, so no new token is needed. If a scheduled task or startup entry appears, remove it first — it will relaunch the old binary and contend with the new service for the ports and PAC setting.
A device shows as Silent in the fleet table. Either the process stopped (check for a service and a listener as above), or the organization's privacy notice was updated and the backend paused the device pending --ack-disclosure. A stopped process leaves nothing running; a paused device is still running but not reporting.
A Windows service fails to start with error 1053. This indicates a binary that does not integrate with the Windows Service Control Manager. Confirm you are running an official release build rather than a plain cross-compiled binary.