Agent discovery reporting (opt-in)¶
Editions: OSS, Cloud, Enterprise
preloop agents discover scans the local machine for known agent tools
(Claude Code, Cursor, Codex CLI and the rest). By default the scan stays on
the machine: it only reads GET /api/v1/agents to mark which agents are
already enrolled. Discovery reporting is the opt-in way to tell Preloop which
agent tools exist on workstations that were never onboarded, so they show up
in the console under Agents > Not yet governed.
For the per-OS distinction between installed, configured, observed active and governed, read the coverage guide. Its collector data and retention inventory includes DPIA inputs and the limits of report-only credentials. Reported devices appear on Device fleet, which can alert when an enrolled agent goes quiet or an expected control is no longer seen.
Turning it on¶
Either pass the flag or set the environment variable:
preloop agents discover --report --json --no-onboard-prompt
PRELOOP_DISCOVERY_REPORT=1 preloop agents discover --json --no-onboard-prompt
Without one of them nothing about the scan leaves the machine. With --json
the report confirmation goes to stderr so stdout stays valid JSON.
Device-scoped token for scheduled runs¶
Reporting needs the report_discovery permission. Owner and admin roles have
it, as does any role that holds the control-plane bundle. For an MDM job that
runs on every workstation, create an API key whose only scope is
report_discovery:
curl -X POST https://preloop.example.com/api/v1/auth/api-keys \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "workstation discovery", "scopes": ["report_discovery"]}'
Such a key can call GET /api/v1/agents/discovery-salt and
POST /api/v1/agents/discovery-reports and nothing else: every other REST
route and every console WebSocket refuses it with 403 api_key_scope_denied.
Run the job with PRELOOP_TOKEN=<key> and PRELOOP_DISCOVERY_REPORT=1.
Here, the scope limits API access; it does not itself bind the key to one workstation fingerprint. Provision a distinct credential per device and check any additional device restrictions supplied by your deployment. See the stolen collector key threat.
What is sent¶
| Field | Content |
|---|---|
workstation_fingerprint |
HMAC-SHA256 of the OS machine id or explicit --device-ref, keyed with a per-account salt the server issues. The raw identifier is not sent. Different account salts produce different references; these remain pseudonymous, not anonymous. |
cli_version |
The CLI version. |
os |
OS family only: darwin, linux, windows or other. |
candidates[].agent_kind |
The product kind, for example cursor or claude_code. |
candidates[].config_path_hash |
For current-user discovery, HMAC-SHA256, same salt, of the config path with the home directory replaced by ~. All-user reports use a separate keyed profile/app context hash, described below. |
candidates[].mcp_server_count |
How many MCP servers the config defines. A count, not names. |
candidates[].enrolled |
Whether the CLI already saw an enrollment for it. |
The API also accepts optional candidates[].agent_version; the current CLI
report builder does not populate it. Optional evidence fields and the exact
hash inputs are documented in the collector field inventory.
The machine id is /etc/machine-id on Linux, IOPlatformUUID on macOS and
MachineGuid on Windows. When none is available current-user reporting
generates a random id once and keeps it in the CLI config directory. All-user
reporting reuses an existing fallback or requires --device-ref; it does not
create an identity file.
What is never sent¶
The shipped report serializers omit user names, hostnames, home or config paths in clear, prompts, API keys or other credentials, environment variables, MCP server names, URLs, commands or arguments. The server rejects unknown fields and constrains hashes, enums, counts and agent-kind/version strings. This validates shape, not the truth or sensitivity of an arbitrary client's schema-valid values. The narrow report body does not establish what HTTP access logs, session records or downstream webhook recipients retain. See the DPIA boundaries.
What the server keeps¶
One discovered_agent_candidate row per (account, workstation fingerprint,
agent kind, config path hash), with first_seen_at, last_seen_at and a
status: new, onboarded or ignored.
- A new row fires the
agent.discoveredwebhook once. A re-report of the same tool only moveslast_seen_atand emits nothing. - For current-user discovery, every enrollment write for an agent from the
same workstation (applied, validated or failing validation, for any agent
kind including MCP-only and IDE-config agents) carries the same two hashes,
and the matching candidate becomes
onboarded, linked to the managed agent. A restore carries no link. When no report was ever sent for that install, the enrollment records anonboardedcandidate itself (the two hashes plus the OS family and CLI version, nothing else), so onboarding alone places the agent on its device in the device fleet. Re-onboarding updates the same row. - Agents enrolled by an older CLI have no such link. The fleet map and the
agent page then place the agent on a device by evidence already held, never
rewriting it: first a runner-reported harness of the agent's kind on a
device its owner uses, when exactly one managed agent fits; otherwise the
agent's enrollment hostname, when exactly one device has that hostname
(compared case-insensitively, ignoring
.localor a domain, with-,_and spaces alike) and the agent's owner, when known, uses that device. Ambiguous matches stay unplaced. - Mark ignored in the console hides a candidate. Copy onboard command
copies
preloop agents onboard <kind>to run on that workstation. - Candidates nobody has reported for 90 days are deleted by a daily purge,
except
onboardedcandidates still linked to a managed agent: those are the agent's device link. Deleting the agent clears the link and makes a stale candidate eligible for a later purge. The retention table distinguishes expiry from actual deletion and other data stores.
API¶
| Method and path | Permission |
|---|---|
GET /api/v1/agents/discovery-salt |
report_discovery |
POST /api/v1/agents/discovery-reports |
report_discovery |
GET /api/v1/agents/discovery-candidates?status=new |
view_agents |
PATCH /api/v1/agents/discovery-candidates/{id} ({"status": "ignored"}) |
manage_agents |
GET /api/v1/agents/discovery-candidates returns {items, total, truncated}.
items is the newest 500 matching rows. total counts every match, and
truncated is true when the fleet is larger than that page, so the console
can say it is showing the first 500.
Harness inventory from runners¶
Separately from this opt-in report, a connected
runner
publishes the harnesses installed on its host with its registration and
heartbeat. It is stored on the runner (harness_inventory) and shown on the
Runners page. Every entry has these fields:
| Field | Content |
|---|---|
harness |
copilot_cli, cursor_cli, claude_code, codex_cli, opencode, gemini_cli, claude_desktop or vscode_copilot. |
display_name |
Product name. |
version |
Harness version, when the runner could read it. Visible to the runner owner and account admins only. |
login_state |
signed_in, signed_out, unknown or not_applicable. Never a token or user name. |
login_source |
env (a token variable is set; only its name was checked), stored, cli_status, none or unknown. |
account_host |
Host of the account (for example github.com), GitHub harnesses only. Visible to the runner owner and account admins only. |
governance |
governed (Preloop hooks and approval hook), partial (usage hooks or MCP only), ungoverned or unknown. |
support_level |
flows_and_sessions, flows_only or presence_only. |
enabled |
False when the host turned the harness off in runner.json. Disabled harnesses are still listed. |
sessions_enabled |
Set on the host only. |
session_mode |
resume, stream, replay or none. |
billing |
seat (subscription, not metered by the gateway), metered or unknown. |
models[] |
Up to 64 {id, source} pairs; source is probed, configured, static or observed. |
generated_profile |
Name of the host execution profile the runner generated (copilot, cursor), or null when a hand-written profile wins. |
capabilities |
Host execution capability flags. |
The inventory carries at most 32 entries and a sha256: hash of its entries.
Executable paths, arguments, environment values and user names never leave
the host. Each change is audited as runner.harness_inventory_changed.
Versioned source observations¶
The existing report accepts an optional evidence envelope with
schema_version: 1 or 2. An installed evidence service consumes it within the same
report transaction; without that service the extension returns 503, while
legacy reports continue unchanged. No second collector endpoint is introduced.
Observations preserve enumerated scan scope/errors, timezone-aware collection windows, completeness, safe app/version facts and independent control assertions. Exact replay is idempotent per authenticated source and workstation; changed replay conflicts. Sources and collection windows remain separate, including empty scans. Retention is 90 days from receipt; the always-on discovery sweeper purges expired observations even if the evidence plugin is disabled or the source stops reporting. None of these records prove device identity, app runtime enforcement or use. Collector assertions cannot carry effective or successful runtime-verification fields.
All-user profile reports¶
Use preloop agents discover --all-users --report from an elevated process
(root on Linux/macOS, administrator on Windows). --all-users requires an
explicit --report or --inventory; the environment variable alone does not
select all-user reporting. The elevated process needs the reporting token or
login. For an offline scan, use --all-users --inventory; this makes no network
calls and prints a preloop.inventory.v2 envelope. Both modes print safe
inventory JSON and do not onboard agents.
The scan enumerates local profiles from /Users on macOS, /home and /root
on Linux, or Windows ProfileList. It covers the known application registry,
not every possible install location or network/directory-service profile.
It scans at most 64 profiles, reads at most 4 MiB per configuration file, and
reports at most 100 application contexts. Symlinks, hard-linked configurations,
special files and unsafe configuration paths are refused. Configuration files
are opened relative to the selected profile with link checks at open time;
the collector never executes a discovered application or reads its credential
store. Reaching a limit or failing to read a profile leaves the
coverage incomplete rather than treating that scope as an empty successful scan.
All-user reports send evidence.schema_version: 2 on the existing report
endpoint. Version 1 reports remain supported. Version 2 adds:
| Field | Content |
|---|---|
evidence.profiles[].profile_ref |
A keyed reference scoped to the account and workstation. It identifies profile scope without sending a profile name or home path. |
evidence.profiles[].completeness |
complete, partial or failed, including profiles that yielded no application rows. |
evidence.profiles[].error_code |
Absent for complete scope; otherwise profile_unavailable, profile_unreadable or probe_incomplete. |
evidence.applications[].profile_ref |
The reference of the profile in which this app context was observed. |
Offline v2 output also associates probe results with profile_ref. Its fresh
per-run random salt makes profile references unrelated across offline runs.
Reported references use the account salt and workstation context instead.
If no profiles can be enumerated, the inventory has completeness: failed and
scope.error_code: profiles_unavailable. An unreadable Windows ProfileList
entry produces its own hashed failed profile, and collection continues for
other profiles. Unsafe registry paths (including network paths, alternate data
streams and traversal) also produce a failed profile without being opened.
If enumeration returns readable profiles alongside an error, those profiles
are scanned and the inventory is partial with profiles_unavailable. On Linux,
/root is still scanned when /home cannot be enumerated.
A profile limit produces partial
inventory with scope.error_code: profile_limit. The report retains enumerated
profile states even when no apps were readable; its evidence records partial
or failed completeness and an unavailable error. A report truncated at 100
apps is also partial. Incomplete scope never proves absence of agents.
All-user candidates represent aggregate profile/app contexts. Their
config_path_hash uses the preloop-profile-app:v2: namespace, while
current-user enrollment uses preloop-config-path:v1:. Consequently these
candidates do not automatically become onboarded when a current-user agent
is later enrolled, and their emitted enrolled: false means enrollment was
not observed. Automatic linking requires a future profile-aware enrollment
contract. App presence and configuration observations do not prove runtime
enforcement or use.
Administrative device identifiers¶
For a fleet roster with an explicit administrative identifier, use
preloop agents discover --report --device-ref synthetic-device. This replaces
OS machine-id discovery for the report fingerprint. The identifier is stripped
of surrounding whitespace, then hashed with the same account salt and
preloop-workstation:v1: namespace; the raw value is never sent. It does not
turn reporting on without --report or the existing opt-in environment flag.
Matching hashes correlate administrative identifiers and do not attest a device,
its executable identity or effective governance. Candidate-only reports establish
receipt history, not collection completeness or trusted control verification.
--all-users --report --device-ref synthetic-device uses this same identifier
for both the workstation fingerprint and its profile references. All-user
reporting never creates a fallback identity file: if the OS identifier is
unavailable, it reuses an existing CLI workstation id or fails with a request
to supply --device-ref. Offline inventory does not look up machine identity.
macOS identity lookup uses the fixed system ioreg path, and Windows reads
MachineGuid directly from the registry, so inherited PATH cannot select a
program to execute with collector privileges.
Deployment and coverage¶
See the MDM recipes for staged delivery and separate credential provisioning, the threat model for bypass risks, and planned work for features that must not be treated as shipped coverage.