Appearance
Policy model
A policy answers three questions independently. Keeping them separate is what lets the simulator explain a decision rather than merely report one.
- Scope - does this policy apply to this user, device, channel at all?
- Condition - did the content and context match?
- Response - what do we do, given what the endpoint can actually do?
Anatomy
jsonc
{
"id": "…", "tenant_id": "…",
"name": "Payment cards and cloud credentials",
"owner": "secops@example.com",
"version": 3,
"status": "published",
"mode": "audit",
"severity": "high",
"priority": 10, // lower wins highest-priority conflicts
"scope": { // empty list = no constraint
"departments": ["Finance"], // not evaluable yet, see below
"channels": ["removable_storage", "browser_upload"],
"operating_systems": ["windows"],
"destinations": ["dropbox.com"], // domain-suffix semantics
"sources": ["C:\\Users"],
"applications": ["chrome.exe"]
},
"condition": { /* tree, see below */ },
"exceptions": [{
"id": "exc-pci-audit",
"reason": "The PCI audit team processes card data by design",
"approved_by": "ciso@example.com", // required
"expires_at": "2027-01-01T00:00:00Z",
"scope": { "departments": ["PCI Audit"] },
"downgrade_to": "audit" // omit to suppress entirely
}],
"response": "block",
"offline_response": "audit", // when the endpoint cannot reach us
"evidence": { "metadata": true, "redacted_previews": true,
"context_snippets": false, "full_file": false },
"schedule": { "timezone": "Asia/Baku", "days_of_week": [0,1,2,3,4],
"start_minute": 480, "end_minute": 1140 },
"conflict_strategy": "most_restrictive",
"change_reason": "Add GitHub tokens after the Q3 incident" // required
}Group scopes are evaluated on Windows endpoints; department scopes match nobody yet (DLP-666, ADR-0030).
A Windows agent fills the signed-in user's groups from their logon token (DLP-670). Each enabled group is offered twice: as its SID, which is compared exactly, and as DOMAIN\Name, compared without regard to case. The name is offered once the agent has looked it up, and a decision never waits for that lookup. Write the SID, because a group renamed in the directory keeps it. Groups used only to deny access, the per-logon SID and the integrity label are not memberships. The limits:
- A group change takes effect at the user's next sign-in, as it does for Windows access control.
- With more than one person signed in to the machine (a terminal server, or a second user switched in), no groups are supplied, because the console user is not necessarily the one acting. A group-scoped rule then matches nobody.
- The Linux agent supplies no groups.
- The policy editor writes a group scope in its "Directory groups of the signed-in user" field (
DLP-756), which recommends the SID.
cloud_storage (DLP-1198, ADR-0042) is the one channel no endpoint observes. It is a SharePoint or OneDrive file read by the server, and a policy scoped to it is evaluated against discovery findings. Its outcome is always an audit: nothing there can be blocked, and publishing a blocking policy for it says so.
No agent reports department, so a department scope never applies. Both scopes are still accepted: the product's rule for channels nobody can observe (coverage.rs) is to warn, never refuse. Publishing a policy whose own scope names an attribute that no active endpoint reports in capabilities.scope_attributes answers with a scope_unevaluable warning naming the values. Windows agents report user_groups there. Exception department/group scopes are checked too (DLP-1444): exception_scope_unevaluable identifies an exception no applicable platform can evaluate, while exception_scope_partial explains that only some active endpoints on the policy/exception platforms report the attribute. Neither means the policy itself matches nobody, nor proves actual group membership. Each saved version shows its current coverage warnings before release, including draft and pending-approval versions. Release warnings carry the exception ID; audit metadata records warning kinds and exception IDs without group membership or the exception's reason. The warning is recorded as policy.published_with_warnings in the audit log, and it stays on the Policies list for as long as no active endpoint reports the attribute.
An empty scope list means "no constraint on this dimension". This is the permissive reading, and the policy builder states it, because an operator who believes empty means "nobody" would publish a policy that silently applies to everyone.
Scope targets by stable identity (DLP-1434)
scope.include_targets names who and what a policy covers by a kind, the source that issued it, and that source's immutable id. A label (a display name or UPN) is for a person reading the policy and is never matched:
json
"include_targets": [
{ "kind": "directory_group",
"source": { "type": "active_directory", "domain_sid": "S-1-5-21-1004336348-1177238915-682003330" },
"object_id": "S-1-5-21-1004336348-1177238915-682003330-1104", "label": "Finance" },
{ "kind": "endpoint", "source": { "type": "mydlp" }, "object_id": "<agent id>" },
{ "kind": "endpoint_group", "source": { "type": "mydlp" }, "object_id": "<group id>" }
]Only what an endpoint can evaluate is accepted. A directory group is its SID within the named domain, matched against the Windows logon token (ADR-0030). An endpoint is its agent id. An endpoint group is its id. Refused, each with the reason: a directory user, because verified interactive-SID capability and compatible endpoint delivery are not yet accepted; anything from Entra ID, because the token carries Active Directory SIDs; a group given by name; a SID outside the domain named; and a MyDLP id that is not a current endpoint or group in this tenant.
Local user identity implementation, not enablement (DLP-1437). The Windows reader obtains TokenUser and TokenGroups from the same WTS interactive token, checks that the console session, sole-user count and logon authentication ID did not change during the read, and supplies the account SID only to EvaluationContext.user_sid. Legacy username matching is retained; the local evaluator also matches an exact account SID in scope.user_ids. A separately reported print/clipboard/browser actor cannot inherit another console user's SID or groups. A reading is reused for at most ten seconds, so session changes can take that long to be reflected. Service and Entra logon SIDs are not AD account targets. No new user-SID capability is advertised and typed directory users remain refused until the real interactive acceptance and mixed-fleet contract are proven. This is not proof of Windows token behavior or Entra/hybrid support.
agent/core/tests/interactive_identity.rs contains the explicit, ignored native acceptance test. On an authorized Windows endpoint, run the built test executable as LocalSystem with exactly one signed-in console user, setting DLP_NATIVE_EXPECTED_USER_SID, DLP_NATIVE_EXPECTED_GROUP_SID and DLP_NATIVE_NONMEMBER_GROUP_SID from an independent local identity check, then pass --ignored --exact a_real_interactive_token_matches_its_user_and_member_group_not_an_outsider. The harness fails on missing identity/expectations and never prints SIDs. A hosted runner with no interactive user cannot satisfy this acceptance. The existing DLP-670 group acceptance stays open and is not replaced by this harness.
The bundle compiler writes each include into the field of its dimension that every agent already evaluates: groups (the SID), device_ids (the agent id), agent_groups (the group's name at compile time, so a rename is picked up by the next compile). An agent that predates typed targets therefore evaluates the same scope. Dimensions combine as they always have: each non-empty one must match. A group deleted after publishing compiles to a name no group can have, so the policy narrows to nothing rather than widening to every group, and the rest of the tenant's bundle still compiles.
A save names an object the author must have found, so it needs the lookup permission for the target's kind (DLP-2128): agents:read for an endpoint or an endpoint group, directory:read for a directory group. Without it the save is refused with a 403 and nothing is written. A publish does not repeat this check.
scope.exclude_targets leaves targets out (DLP-1440), accepted on the same terms as inclusions. An exclusion wins over every inclusion: "Finance, except the interns" includes the Finance group and excludes the Interns group, and a person in both is out. An exclusion alone means everyone but the excluded. The compiler writes them into exclude_groups, exclude_device_ids and exclude_agent_groups, which the engine checks after the inclusions. An excluded endpoint group that no longer exists excludes nobody, which is what it now holds; unlike an inclusion, a missing exclusion cannot widen a scope.
The three exclude_* fields are written by the compiler only. A save that sets exclude_groups, exclude_device_ids or exclude_agent_groups directly is refused with a field error, since the publish gate counts only exclude_targets and such an exclusion would go uncounted (DLP-2128). A stored version that already carries one is refused at publish until it is saved again with the exclusion as a target.
A version with an exclusion is published only while every active endpoint reports scope_exclusions in its capabilities, and the refusal says how many do not. An agent older than that would ignore the exclusion and enforce on exactly what was excluded. An endpoint that arrives afterwards, or stops reporting the capability, is not handed a bundle carrying an exclusion (DLP-1484): its fetch answers refused: {code: "upgrade_required", missing: ["scope_exclusions"]} with no bundle, and check-in does not promise it the update. It keeps enforcing the bundle it last verified, which is the older one without the exclusion, until its agent is updated. A bundle with no exclusion reaches every endpoint, as before. The policy's Deployment view counts such endpoints in their own band, Agent update needed, and never as current (DLP-1485).
Conditions
| Node | Meaning |
|---|---|
classification | One exact {classification_id, classification_version, revision} from the signed bundle |
detector | One detector, with min_matches, min_confidence, require_validated |
category | Any detector in a data category, aggregated |
and / or / not | Boolean composition |
at_least | n of the children are true |
total_matches | Matches across all detectors - the bulk-export rule |
file_condition | Extension, MIME, size bounds, path fragment, label, exact owner SID/name |
removable_device | Which removable device the file is going to - serial, class, or both |
network_location | Where the endpoint is on the network: corporate, vpn, off_network, unknown |
always | For discovery-only inventory policies |
Classification bindings are immutable coordinates, not floating references. The server refuses a missing, disabled, cross-tenant or stale revision when a draft is published and revalidates the complete published graph before it signs a bundle. A published binding also prevents its classification from being changed or disabled until the policy is replaced or withdrawn.
Boolean evaluation is three-valued for classification nodes. not_evaluated never becomes a match merely because it sits below not; an and can still be disproved by a known false child, and an or can still be proved by a known true child. Decision evidence retains the exact coordinate, matched rule and contributing element/detector ids, plus bounded reason codes-never values. Legacy detector nodes remain readable and keep their exact thresholds; new authoring selects first-class classifications. Rewriting an old detector node through a default compatibility classification would lose its thresholds, so the compatibility window is intentionally additive rather than destructive.
Classification labels
file_condition.labels matches labels read from the file (including a Microsoft sensitivity label) case-insensitively. A policy may also request an additive agent label:
json
"apply_label": { "value": "confidential", "replace_existing": false }The endpoint writes only its MyDLP.Label metadata stream on Windows. A different existing MyDLP label is preserved unless replace_existing is explicitly true; customer-owned labels are never written, downgraded or removed. The label outcome is included in the policy explanation and does not change the response action (audit, inform, warn_with_continue, block, and so on).
Inform and Warn
inform is a non-blocking notice: the protected operation continues without waiting for a user choice. The historical wire action warn had this same behavior and is read as inform by updated agents. The action warn_with_continue is distinct: it holds the operation until the user chooses Cancel or Continue. The management API withholds policies that require it from agents that do not advertise it. Windows agents do since DLP-1883. On a held operation, which today means a browser upload through an extension that keeps it held, the person chooses on the warning screen. Continue records user_continued and lets the upload proceed. Cancel, or no answer, records user_cancelled and blocks it. On an operation that was not held, such as a file already written, it is a notice, as inform is. The frozen Linux agent does not advertise it. Stored policy versions are migrated from warn to inform; historical audit events and already signed bundles retain their original bytes and semantics.
Approval-required response
approval_required is not require_justification: typing a reason cannot release the operation. On a held operation it stays blocked until a separately authorised reviewer approves an explicit, operation-bound exception request. Pending, rejected, withdrawn or expired requests do not release anything. A hard-block policy still wins over approval-required when both match. The Windows agent reports this response action as a capability; the server withholds a bundle that uses it from an endpoint that does not report support, rather than letting an older agent misread the unknown action. See ADR-0054.
File owner
An owner constraint is an exact, case-insensitive match against either the stable Windows SID or the resolved principal name. Prefer the SID for durable rules: directory lookup can be unavailable and a principal can be renamed, while the SID remains the identity Windows used for the access check.
json
{ "op": "file_condition",
"owners": ["S-1-5-21-111-222-333-1001", "CONTOSO\\finance-service"] }The values in owners are alternatives. A failed name lookup leaves the name absent; the evaluator does not guess a name or use substring matching.
network_location
"Card numbers to a USB stick are audited in the office and blocked off the network." The endpoint decides where it is from the indicators the tenant sets (POST /settings/network-indicators) and reports it at each check-in (DLP-1147). A rule names the locations it is about (DLP-1148):
json
{ "op": "network_location", "any_of": ["off_network", "vpn"] }- Nothing reported matches nothing. An endpoint that has not said where it is (an older agent, or a simulation) is not "off-network". Reading it that way would fire an off-network rule on every such endpoint.
unknownmatches only a rule that names it, and is never the corporate network. An endpoint saysunknownwhen it could not read its networks, and every endpoint says it while the tenant has set no indicators.- Never the whole reason to block. A policy whose response, online or offline, is
blockorquarantine, and whose condition has nothing but location in it, is refused. Location is context, and a laptop that moves changes it between two check-ins. An audit or warn rule may use it alone. - VPN means a tunnel adapter carries a corporate DNS suffix, not that the corporate VPN is routing the traffic, which no API can say.
removable_device
The rule an organisation with a USB policy actually wants: "card numbers on removable media - unless it is one of the encrypted sticks we issued". Without it the only available policy treats the corporate stick exactly like the one somebody found in a car park, and an alert that fires on the approved devices too is an alert somebody turns off.
json
{ "op": "removable_device",
"serials": ["SN-ISSUED-0001", "SN-ISSUED-0002"],
"classes": [],
"match_mode": "none_of" }match_mode is any_of ("any optical disc") or none_of (the allow-list). Two modes rather than wrapping the node in not, because the allow-list is the rule everybody writes and not { removable_device { … } } reads backwards at exactly the moment somebody is deciding whether their approved sticks are covered.
serials and classes are one membership test, not two conditions: a device is listed when its serial is in serials or its class is in classes. So none_of with both reads as "neither one of ours nor a phone".
Three behaviours worth knowing before writing one:
- A device with no serial is never admitted by an allow-list. Not every platform and not every device yields one, and an approval list that silently admitted every unidentifiable device would read as an approval list and behave as a decoration. It is treated as unapproved, and the incident's explanation says the device carried no serial rather than leaving a reader to guess whether it was checked.
- An event with no removable destination matches neither mode. "No device" is not "a device that is not one of ours" - reading it that way would make an allow-list fire on every e-mail and every browser upload in the fleet.
- A phone is governed by class and never identified. A handset is never reported with a serial, which is a privacy property the agent asserts in its own tests; a rule about phones therefore names the class.
Serials are matched without regard to case, because platforms disagree about it and an approval list that failed on exactly one operating system would look like a broken policy rather than a broken comparison.
A serial is firmware data, and firmware is attacker-controlled
This is the sentence to read before writing an allow-list. DLP-178 made the agent ask the device for its own vendor, product and serial - the hardware's answer rather than a drive letter or a volume serial, both of which name whatever was plugged in last and do not survive a reformat.
It identifies a device for convenience. It is not an authentication. A serial is a string the device's firmware reports. Two devices can report one serial, by mistake or on purpose, and on hardware that permits it a serial can be rewritten to whatever an attacker likes. Anybody who can supply the hardware can supply the answer.
So an allow-list keyed on serials is a workflow control, not a security boundary: it stops the ordinary case of somebody reaching for the wrong stick, and it does not stop somebody who set out to defeat it. Where the difference matters, the control is encryption - a device that cannot read what was written to it is a device that gained nothing by claiming a serial - and SPIKE-014 is where that is decided.
The product never treats a serial as sufficient on its own, and a rule that does should be written knowing that is what it is.
Where the list lives (DLP-287), and how a policy uses it (DLP-903)
Assets → Device control (its own address since DLP-529, ADR-0027; under Assets, ADR-0025). One inventory per tenant, rather than a list retyped into every policy that needs one - copies drift, and a stick approved in one policy and missing from another reads, to whoever is looking at the incident, as an approved device being flagged. The fix somebody reaches for then is removing the rule.
A condition uses the inventory by setting include_approved_inventory: true (in the policy editor, Use approved devices). When the server compiles a bundle it adds every approved serial to that condition's serials, so an endpoint evaluates plain serials and never holds the inventory itself. Approving or removing a device compiles a new bundle when a published policy uses the list, and the answer says which bundle, or that none was needed; the policy_bundle.compiled entry names the approval or removal as its cause. A condition without the flag is not touched by an approval. One organisation may approve at most 5,000 devices, because each serial is written into every condition that uses the list.
Until DLP-903 no condition could use it: nothing outside its own module read the table, and approving a device changed no bundle while the page said endpoints applied it.
Approving stays under policies:write: an approval changes which events a policy matches, which is what writing a policy does, and anybody who may write a policy could already type the serial into one.
The page is arranged around the half that makes it usable: devices the fleet has actually met come first, and approving one is a button on a row that already has the right characters in it. Nobody knows their serials - they are twenty hexadecimal characters on a sticker nobody reads - and a typed one approves a device that does not exist while the real one keeps raising incidents.
The serial reaching the console at all is new with it: a policy could already match on a device at the endpoint, and the server kept no record of which device, so an allow-list was enforceable and not auditable.
Enforcement is a separate question: the product is monitor-only on this channel (DLP-6, DLP-179) - removable storage is monitored, and on Windows warned about, but not blocked on any platform - so an approval changes which events a policy matches and nothing about whether a device can be written to. The condition is useful before enforcement lands because it is what makes the event worth acting on.
and and or deliberately do not short-circuit. Both evaluate every child, because the explanation an analyst reads should say why each part did or did not hold, not stop at the first failure.
require_validated: true is the difference between "a 16-digit number" and "a 16-digit number that passes Luhn". Use it for cards and IBANs.
Exceptions
An exception applies when its scope matches and it has not expired. It either suppresses the policy or downgrades the action, and either way the incident records which exception applied and who approved it.
An exception with a completely empty scope is refused at write time. Under the "empty means no constraint" rule it would apply to everyone and silently disable the policy. The evaluator also ignores such an exception defensively, but the API rejects it so an operator finds out immediately rather than wondering why their policy stopped working.
approved_by is mandatory. An exception without an owner is an audit finding.
Conflict resolution
When several policies match, the strategy comes from the highest-priority matching policy, so one policy cannot impose a permissive strategy on a stricter peer.
| Strategy | Winner |
|---|---|
most_restrictive (default) | Strongest action; ties broken by priority then severity |
highest_priority | Lowest priority number; ties broken by action strength |
least_restrictive | Weakest action. Requires deliberate opt-in |
Action strength: audit < inform < warn_with_continue < require_justification < approval_required < block < quarantine.
Losing policies are recorded on the event as superseded_policy_ids - an analyst can see what else would have fired.
How often a match reaches the queue
A match becomes an event always, and an incident through correlation: the same person doing the same thing to the same content under the same policy on the same endpoint folds into one incident with a growing event count, inside a 24-hour window. Creating, changing, renaming, copying and deleting a file whose content is known count as the same thing (DLP-1363); taking it out, on a stick, in an upload or to a printer, is its own. That is the default and the reason a queue stays readable when a rule matches a thousand times in an afternoon.
A discovery walk is not a person doing anything, so its sighting is matched by content, endpoint and policy alone, with no window (DLP-1375): it joins the latest incident about that content at rest, unless that incident was resolved or closed, in which case the data being back is a new incident. An accepted risk or a false positive is a verdict on that data, and a walk finding it again joins it rather than reopening the question. Without this, every full walk, such as the one after an agent update, opened a second incident for every file that already had one.
json
"raise_every_match": trueTurns folding off for that policy. Every match raises its own incident, and the console's Incidents column says Always instead of Default so an operator can see which rules do this without opening them.
It is per policy and opt-in for one reason: an operator who sets it everywhere has turned the queue back into a log, and the point of a queue is that somebody reads it. Use it where the second occurrence is a different fact from the first rather than more of it.
Three things it does not do. It does not change what is recorded - every match was already an event. It does not rewrite history: the flag is read from the published version, so incidents keep the behaviour of the rule as it was published. And it does not split an incident somebody is already working - the correlation row it would have found is left untouched.
Risk thresholds
A policy may pick its action from the risk of the specific event rather than applying the same one to everyone. risk_thresholds is a table the author wrote - below, then steps of at_or_above and action - not a curve the product tuned:
below: audit
steps:
- at_or_above: 40 action: inform
- at_or_above: 70 action: require_justification
- at_or_above: 85 action: blockThe highest step the score reaches wins, whatever order the steps are written in. A score reaching no step falls to below, which is stated rather than assumed: a table that silently does nothing under its lowest threshold is a rule whose quiet case nobody chose.
Where it sits in the order, decided once:
- Scope, status, schedule, condition - does this policy apply at all?
- Exceptions, which may downgrade or suppress.
- Risk thresholds - skipped entirely if an exception applied.
- Conflict resolution across policies, on the actions produced above.
- Capability clamping, which is always the last word.
Two of those placements are the decisions the ticket asked for:
- An exception outranks the table. An approved exception is a person's decision with a name against it. An approval that held only until an event happened to score highly would not be an approval.
- A dynamic action never changes which policy wins. Conflict resolution runs afterwards, unchanged, on whatever actions the tables produced. If a risk score could promote a policy over a higher-priority one, the order an author wrote could be silently reversed by an event's circumstances, and no screen would show it.
Every dynamic decision has to be explainable to the person it was applied to. That is a requirement, not a nicety: a control that varies by person will be argued about in an HR meeting rather than a security one. So the explanation records the score, the band, the model version, which step fired, and the three factors that actually moved the score - never one that contributed nothing, because listing "standard user" as a reason for blocking somebody tells them something untrue about why.
When nothing scored the event, a policy with thresholds falls back to its standing action and says so loudly. A dynamic rule that silently behaves statically is a rule whose author believes it is doing something it is not.
A malformed table is refused while the author is still looking at it: no steps, a threshold above 100, two steps at the same score, or a higher score producing a gentler action. A malformed table discovered on an endpoint is a rule that behaves unpredictably on one machine and correctly on the next.
Capability clamping
This is where the product's honesty is enforced, in one function.
After conflict resolution, the decided action is clamped by ChannelCapability::clamp() to what the endpoint can actually do on that channel. quarantine on a block-capable endpoint becomes block; block on a monitor-only endpoint becomes audit.
The capability is the endpoint's own report of what it is running, not a property of its platform. A Linux endpoint enforcing opens (#387) reports file_open as block-capable and a block there is not clamped; the same build on a host without CAP_SYS_ADMIN reports that it cannot, and the same policy clamps to audit with the reason.
The event records both:
json
{ "action": "audit", "requested_action": "block",
"explanation": ["…", "Endpoint capability limits this channel to audit."] }The dashboard counts the difference under "actions the endpoint could not enforce". Live example from this deployment:
deploy-notes.md | requested=block -> applied=audit
customer-export-q3.csv | requested=block -> applied=audit
board-memo.txt | requested=warn -> applied=auditLifecycle
draft ──────────────────────────────► published ──► disabled
│ ▲ ▲ │
│ └── rejected, with a reason ──┐ │ ├──► archived
└──► pending_approval ───────────┴─────┘ └──► rolled_back
(submitted) approved by a second personEvery version is immutable once created and carries a mandatory change_reason. Publishing archives the previously published version and compiles a new bundle. The database row owns that lifecycle. A published sandbox version keeps status: "test" inside its signed specification, while its database version is published; ordinary releases carry status: "published".
Sandbox policies are evidence, not protection. The server accepts a test version only when its online and offline actions are audit, it has no risk-driven action, label change or connection control, it names at least one exact endpoint and no person or group target, and it expires within 31 days. The endpoint engine already evaluates the test status, but the policy cannot change an operation. Incidents raised by it are marked as tests, excluded from production risk/overview calculations, and use a separate correlation key so a later production release cannot fold real activity into test evidence. Changing effective_until is optional for ordinary policies; once the window ends the endpoint evaluator no longer applies the policy and the console shows it as expired rather than healthy protection.
A second person releases a rule the author cannot approve (DLP-536). Publishing needs policies:publish. Somebody who also holds policies:approve - Owner and Admin - publishes directly. Somebody who does not - a Security Analyst, or an API token scoped without it - submits: the version becomes pending_approval, no bundle is compiled, and it waits for another holder of policies:approve to approve it (which publishes it) or reject it (which needs a reason and returns it to draft). The approver may be neither the version's author nor its submitter, and both are checked inside the same lock that compiles the bundle. One version of a policy waits at a time, and it is the latest, so a newer draft cannot bury a submission. An API token acts as the person who created it, so an administrator cannot submit through a token and approve in the console.
What this does not do: an administrator's own draft needs nobody else, because a tenant with one administrator has to be able to change its rules, and approved_by stays empty for it rather than naming the publisher. A rollback returns only to a version that was published before, so it cannot publish a draft or a waiting version around the approver; and publishing applies only to the latest draft, so re-publishing an old version is always a rollback, with its reason and its record (DLP-608). Every refused lifecycle action is audited, so an author trying to approve their own rule is on the record.
Nor does it cover the actions that change a published rule without a new version. Disabling a policy, reordering policies and rolling one back each need policies:publish and nobody else, so a Security Analyst may do any of them alone. Each asks for a reason and is audited, but none waits for an approver.
Rollback moves the bundle version forward. Republishing an older policy version produces a higher bundle version. That is deliberate: the agent's anti-rollback check refuses any bundle below the highest it has accepted, so a rollback that decreased the bundle version would be indistinguishable from an attacker replaying an old bundle, and would be correctly rejected by every agent.
Drafting a version in the console (DLP-659)
A policy's page offers Draft next version (/policies/{id}/draft) to anyone holding policies:write. The editor starts from the latest version's whole spec and replaces only what it shows:
- name, description and severity;
- the action, and the offline action;
- channels, destinations, applications and device groups;
- exceptions: reason, approver, expiry, the action they fall back to, and their device-group and destination scope;
- the weekly schedule and optional policy expiry;
- audit-only sandbox mode, constrained to exact endpoints and a short expiry;
- the user notice and the evidence kept;
- a condition made of classifications, any or all of them.
Everything else is sent back exactly as it was, and the editor lists what it kept: departments and directory groups, named users and devices, operating systems, source paths, label actions, risk-adaptive responses and the effective start date. A condition in any other shape (detectors, categories, file or device conditions, nesting) is kept too, and changed through the API.
Saving writes a draft with the reason given. Releasing it is unchanged: submitted and approved by a second person, or published by an approver, from the version's page. The editor does not open while another version waits for approval, because the server refuses a new version then. The whole flow is proved in a real browser by e2e/policy-editing.spec.ts.
Connection policies (ADR-0057, DLP-2070)
A policy can carry a connection rule instead of reading content:
json
"connection": {"mode": "deny", "applications": ["powershell.exe"],
"destinations": [{"address": "203.0.113.0/24", "ports": [443]}]}mode is allow_only (the applications may connect only to these destinations) or deny (they may not connect to them). Applications are .exe file names, never paths. Destinations are addresses or CIDR ranges, never hostnames. ports left out means every port.
The server refuses such a policy unless its response is block and its condition is exactly {"op":"not","node":{"op":"and","nodes":[]}}. That condition never matches a file. An agent from before this field reads the policy as a content policy, so anything that could match would have it block files. Its scope may name endpoints, endpoint groups and operating systems, and nothing about people or content flows. A filter is the whole endpoint's. Each refusal names its field: connection.applications, connection.destinations, condition, response or scope.
What exists today:
- The server accepts, validates and signs it.
- The Windows agent works out the filters it asks for and installs them in a dynamic WFP session (
DLP-2072). It reportsconnection_filtersin health: wanted, in force, and why not. - Other platforms report the filters as not in force.
Not shown:
- The filters doing their job. They compile and are not yet run on a Windows host.
- An event and notice for a refused connection on a host. Each refused connection becomes a
network_connectionevent naming the application and the address, at most one a minute per application and destination, and the sole signed-in person sees a notice. That is built and tested here, not yet on Windows.
A known bypass: WFP names an application by its full path. A name is resolved to the copies in the system folders, PowerShell's folder and the PATH. A copy elsewhere, or a renamed one, is not covered.
Offline behaviour
offline_response applies when the agent cannot reach the server. The common setting is a blocking policy that degrades to audit offline, trading enforcement for availability. Omitting it means the online action applies offline too.
Events carry evaluated_offline: true, and the incident timeline shows it, so an analyst knows the decision was made without server context.
Schedules
Day-of-week and minute-of-day windows, evaluated in the policy's own IANA timezone. A schedule means what it says: 08:00-19:00 in Asia/Baku is the Baku working day, not a window that happens to open at noon there.
The IANA database is compiled into both the server and the agent, so a zone resolves identically whether a policy is evaluated on an endpoint, in the simulator, or by the server - and it does not depend on the host carrying tzdata, which a container image usually does not.
Daylight saving is followed, because the zone is a zone and not an offset. A 09:00-17:00 window in Europe/London opens at 09:00 GMT in January and 09:00 BST in July, which are different instants. This is also why an offset such as GMT+4 is refused as a timezone: an offset cannot follow a transition, so a policy written with one would silently be an hour wrong for part of the year.
A window that crosses midnight belongs to the day it starts on. Friday 22:00-02:00 is open at 01:00 on Saturday. Reading the day list against the instant's own day would close the window at midnight, which is precisely the half an out-of-hours policy is written to cover.
The day of week is the local day. An event at 23:00 UTC on a Friday is Saturday in Baku, so a weekdays-only policy does not fire on it and a weekend policy does.
Boundaries. start_minute is inclusive and end_minute exclusive, so 09:00-17:00 and 17:00-01:00 written by two operators meet exactly once rather than overlapping at 17:00.
An unresolvable zone leaves the policy active, and says so on the decision:
The policy's timezone 'Mars/Olympus_Mons' is not a known IANA zone, so its schedule could not be applied and the policy was evaluated as always active. Correct the timezone on the policy.
This fails towards detecting more than asked. The alternative - treating an unreadable schedule as closed - switches the policy off silently, and the absence of incidents is indistinguishable from a quiet week. The server refuses to save such a policy in the first place, so this path should only be reachable for a bundle written before the validation existed.
Unknown inputs
Several risk factors are three-valued, not two: destination trust, application management, and privilege level. None means the endpoint could not determine it, which is a different statement from false.
The distinction is kept all the way into the score and the explanation, and the unknown is scored at the midpoint rather than at either end. Treating an unknown as benign lets a gap in telemetry lower a score; treating it as hostile raises one on every platform we cannot fully interrogate. The midpoint says what is true - we do not know - and moves the score least.
The explanation says so in words as well. "Privilege level could not be determined" is a statement an analyst can act on; "Operation ran as a standard user" is an observation, and printing it about an event nobody observed is how an incident record acquires a fact that was never true (#486).
The simulator passes None for all three, because it is not told them.
A file gone soon after it was found
When a deletion or a move out of view (not the old side of a rename, which is the same file under another name, DLP-1908) joins an incident (DLP-1553) within 15 minutes of the first sighting at that path, the incident gains the departure_soon_after_detection factor and 10 points, once (DLP-1831). Its sentence gives the interval and says the file was moved, cleaned up or removed without saying which: Windows reports a delete and a move out of view the same way, and the owner tidying up is the commonest cause. The factor is added to the incident's stored factors when the departure joins. The model version the incident was raised under is not changed, and nothing older is rescored.
Overrides and approvals
Two inputs come from what a person did before, not from the event (DLP-1833).
Attempts to get past a control. bypass_attempts counts, for the same person over the 30 days before the event, two things already recorded: events where they went past a warning or block by justifying it (enforcement_resultuser_justified), not counting the event itself, and bypass codes an endpoint refused when they entered them (agent.bypass_attempted with accepted false, DLP-1562). An accepted code is not counted, because somebody issued it. The person is matched by the account name the endpoint reported: whole, or, because the agent reports who entered a code by bare account name and an event names DOMAIN\\name, by the account part on the event's own endpoint only (DLP-1907). The count is on a log scale that saturates at five: one attempt adds nothing, because an override is allowed and one is routine, and somebody who does it again and again is the pattern the factor is for. Cancelling, retrying and renaming are not counted; the endpoint does not report them.
Approvals. approved_exception halves the score when the endpoint says it applied an approval and the server holds that approval, for that device, as approved, in force when the operation happened and for that file's content (DLP-1929). The endpoint's word alone does not lower a score: an approval the server does not have, or one still pending, changes nothing.
Before risk-1.4.0 both were constants on the server, so the first never moved and the second never applied. Stored scores are not recomputed: an event joining an incident can only raise its score, and the incident keeps the model version it was raised under.
Simulation
POST /api/v1/policies/simulate runs the same detection engine and the same policy evaluator the endpoint runs - the crates compile into both binaries. It returns matched policies, per-policy explanations, the applied exception, conflict resolution, the final action, the risk score with its factors, and the evaluation duration.
A separate "preview" implementation would drift from the enforcement path, and an operator who cannot trust the preview will never publish a blocking policy.
Bundle signing
Published policies compile into a bundle containing the policies, exact classification definitions, their detector primitives, the monitored paths and the signing key id. Collections and binding coordinates are sorted before the payload is Ed25519-signed over the exact transmitted bytes, so identical input has identical payload ordering and verification does not depend on serialiser field ordering.
The agent checks, in order: algorithm, known key id, signature, envelope key id matches the signed payload's, schema compatibility, tenant, device scope, group scope, expiry, clock skew, and the monotonic version floor. Any failure keeps the previously verified bundle in force, because falling back to "no policy" is exactly what an attacker would want.