Skip to content

Telling the person, claim by claim ​

WIN-DLP-EPIC-021 asks to tell the person at the keyboard, collect a justification, and never lie to them - because most of what a DLP deployment prevents is prevented by people knowing they are recorded.

All three are built, and the third - the one stated as a prohibition - is the one this audit found a hole in. The product knew it had clamped an action, recorded that fact for querying, and still showed the person the sentence written for the action it did not take.

Implementation: agent/core/src/notify.rs for warnings, agent/core/src/justification.rs for validation, agent/core/src/session_ui.rs for the native Windows dialog, and session_link::PromptDesk for carrying the question into a session (DLP-113).


1. Never lie to them ​

ClaimProved by
A message naming a detector is refusednotify::tests::a_message_naming_a_detector_is_refused
The default message names nothing it should notnotify::tests::the_default_message_names_nothing_it_should_not
An empty or enormous message is refusednotify::tests::an_empty_or_enormous_message_is_refused
A policy that asked to warn still warns, with unusable text replacednotify::tests::a_policy_that_asked_to_warn_still_warns_with_unusable_text
A clamped action is corrected where the person reads itnotify::tests::a_clamped_action_is_corrected_where_the_person_reads_it
…and an unclamped one acquires no correction it does not neednotify::tests::an_action_that_was_not_clamped_says_only_what_the_author_wrote
…and the default is corrected toonotify::tests::the_default_message_is_corrected_too_when_the_action_was_clamped

The first three are about what a message must not contain: naming a detector teaches whoever reads it how to avoid the detector, and quoting the match puts the sensitive value on screen in an organisation where the point is that it should not be. Both are refused in is_fit_to_show rather than trusted to the caller, "because the caller is a policy somebody wrote in a hurry".

The hole (DLP-135). user_message came from the policy verbatim. A policy author writes one message for a fleet and cannot know which machine it will appear on - some can prevent an operation, some can only watch it. So "This upload has been blocked" was a sentence the product would show on an endpoint where nothing was blocked.

The agent already knew. PolicyOutcome carries requested_action separately from action precisely so that "the policy said block and this endpoint can only audit" is a queryable fact. It was queryable and unsaid.

message_for_outcome now appends, when the two differ:

Your organisation's rule asks for this to be stopped. This computer could not stop it, so it has been recorded instead.

(Reworded under DLP-1265. It used to read "This endpoint recorded the action. It was not prevented here.": "endpoint" is not a word the person uses, and "not prevented" read to people as permission.)

Appends rather than replaces, deliberately: the agent cannot read the author's prose and does not know whether it claimed prevention, so replacing a message that was accurate would lose context somebody chose to give. A sentence that is true either way costs a line and cannot mislead.

There is deliberately no shorter enforce that omits the requested action. One existed for an afternoon; clippy noticed nothing called it, which was luck. A convenience wrapper that skips the correction is how this comes back.

2. Collect a justification ​

Twenty tests, and they are mostly about refusing answers that are not answers.

ClaimProved by
A real reason is acceptedjustification::tests::a_real_reason_is_accepted
Two words are not enough and three arejustification::tests::two_words_are_not_enough_and_three_are
One word repeated is not several wordsjustification::tests::one_word_repeated_is_not_several_words
A keypress is not a reason, however longjustification::tests::a_keypress_is_not_a_reason_however_long
Typing the category back is not a reasonjustification::tests::typing_the_category_back_is_not_a_reason
An essay is refused before it is storedjustification::tests::an_essay_is_refused_before_it_is_stored
Every rejection says what is wanted rather than what was wrongjustification::tests::every_rejection_says_what_is_wanted_rather_than_what_was_wrong

That last one is the difference between a control people comply with and one they learn to defeat. Told "too short", somebody pads. Told what a reason looks like, somebody writes one.

And the outcomes are kept distinct, which is what makes an incident actionable:

OutcomeMeaning
Given / InheritedThey answered. declining_is_recorded_as_cancelled_and_is_the_control_working
DeclinedThey stopped - the best outcome this control has
NoAnswerSomebody was asked and said nothing. nobody_answering_is_not_recorded_as_a_justification
CannotAskThere was nobody to ask

Coalescing keeps a burst of operations from becoming a burst of dialogs, and is scoped so it cannot become a blanket permission:

ClaimProved by
One answer covers a burst to the same placejustification::tests::one_answer_covers_a_burst_to_the_same_place
An answer about one destination does not cover anotherjustification::tests::an_answer_about_one_destination_does_not_cover_another
An answer about one policy does not cover anotherjustification::tests::an_answer_about_one_policy_does_not_cover_another
An answer expiresjustification::tests::an_answer_expires
Expired answers are dropped rather than accumulatingjustification::tests::expired_answers_are_dropped_rather_than_accumulating
What is recorded says which answers were one decisionjustification::tests::what_is_recorded_says_which_answers_were_one_decision

The last row is for the analyst: fifty identical justifications should be legible as one decision rather than fifty.

3. Tell the person at the keyboard ​

ClaimProved by
A usable message is shown as writtennotify::tests::a_usable_message_is_shown_as_written
Nobody logged in is not a failurenotify::tests::nobody_logged_in_is_not_a_failure
The dialog does not outlive the person's attentionnotify::tests::the_dialog_does_not_outlive_the_persons_attention
A platform that cannot warn says whynotify::tests::a_platform_that_cannot_warn_says_why
Warning is claimed only where something actually shows oneplatform::tests::warning_is_claimed_only_where_something_actually_shows_one

On Windows the mechanism is WTSSendMessageW. On Linux there is none, and the capability says so rather than claiming a warning nobody would see - which was a real defect once: enforce used to return Applied without anything being displayed, "harmless only because no platform claimed can_warn".

4. Answer the question nobody asks in a dialog ​

A warning arrives at the moment of an action and has to be short. It cannot answer the question somebody actually has the first time one appears - what is this software, what has it been watching, and how do I get rid of it? - and a person who cannot get that answered locally goes and asks somewhere worse.

dlp-agent ui writes a page and opens it in the browser already on the machine. It is not a window: there is no toolkit here, and there is no port.

ClaimProved by
An unenrolled machine gets a page rather than an errorui_cli::an_unenrolled_machine_gets_a_page_rather_than_an_error
It runs nothing and fetches nothingui_cli::the_page_can_be_read_offline_and_runs_nothing, ui::tests::the_page_reaches_nothing_outside_itself
A path that looks like markup is rendered as textui::tests::a_path_that_looks_like_markup_is_rendered_as_text
What was found is never on the pageui::tests::what_was_found_is_never_on_the_page
A channel that cannot block says so, and says whyui::tests::a_channel_that_cannot_block_says_so_and_says_why
A partly covered channel shows the mechanism and the gapui::tests::a_partly_covered_channel_shows_the_mechanism_and_the_gap
The page is the person's own file, 0600ui_cli::the_page_is_the_persons_own_file
It is written in the reader's language, chosen as a warning's is: English, Azerbaijani, Turkish or Russian, with <html lang> to match (DLP-1371)ui::tests::the_page_is_written_in_the_readers_language_with_nothing_left_in_english, ui_words::tests::every_translation_keeps_the_placeholders_english_has
A language the product does not ship reads Englishui::tests::a_language_this_product_does_not_ship_reads_english
A path that looks like markup is text in every languageui::tests::a_path_that_looks_like_markup_is_text_in_every_language

What stays English on a translated page, on purpose (DLP-1381): sentences the page shows but does not write. These are a channel's mechanism, gap and unavailable reason (from the capability model), how the private key is protected, the platform support verdict, and why the uninstall leaves a path in place. They are the same facts dlp-agent capabilities and the console show, in English. Translating them only here would turn one fact into four that drift apart. On a page in another language each one is marked lang="en", so a screen reader pronounces it as English, and the page says once, in the reader's language, that technical details are in English (ui::tests::a_technical_sentence_on_a_translated_page_is_marked_english_and_said_to_be). | The removal instructions name what the uninstall deletes | ui_cli::the_removal_instructions_name_what_the_uninstall_actually_deletes |

Three of those rows are the point of the feature rather than incidental to it.

The unenrolled row. Every other command that needs an identity refuses without one, correctly. This one must not: somebody asking what the software is has usually just found it, and a page that appeared only after the machine was registered with a server would appear only for people who already knew.

The offline row. A page that fetched a stylesheet would be blank on the disconnected laptop most likely to be reading it - and would tell somebody it had been opened, which this page has no business doing.

The escaping row. Everything variable on the page came off the machine, and a file path is something a person chooses. A directory called </style><script> is legal on every platform the agent runs on, and the page that rendered it unescaped would be a stored scripting bug delivered by the security agent.

Why a file and not a window: the agent already links 245 crates, and a cross-platform GUI toolkit would add roughly as many again - a windowing stack, a renderer, two display protocols - to a binary that runs with privilege on somebody's endpoint. Why not a local HTTP server: a listening socket on every endpoint in the fleet is an attack surface that has to be bound, authenticated and firewalled, for the whole life of the agent, to serve a page somebody looks at twice a year. A file has no port.

There is no uninstall button, and that is the design ​

The page states the command and leaves the act on the other side of a deliberate shell session. A page any signed-in user can open, carrying a control that removes the security software from the machine, is not a security product; one that asked for an administrator's password would be teaching people to type that password into a local HTML file.

What the page does instead is tell the truth about removal, which the command was not quite doing. It announced "identity, cached policy, event queue and local history removed" and left the EDM HMAC keys and the agent's own logs on the disk. Key material outliving the software that used it is the defect worth naming: nobody goes looking afterwards, because the software said it was gone. Both are removed now (uninstall_leaves_nothing::the_edm_keys_do_not_survive_the_uninstall).

Quarantined files are deliberately not removed. They are the organisation's own data, held because a policy said to hold it, and an uninstall is the wrong moment for software to decide that data should be destroyed. They are left, and counted, so somebody knows they are there (uninstall_leaves_nothing::quarantined_files_are_left_and_the_removal_says_so).


5. How the dialog is laid out (DLP-470) ​

The prompt is drawn at 96 DPI design units and scaled to the display it is on. session_ui::layout holds every control's rectangle and the scaling, and it is outside cfg(windows) so it is asserted on every platform rather than only by a Windows job - CLAUDE.md §6 is the rule, and this module is one of the reasons for it. Five tests walk the constants: at 100% they are unchanged, at 125% the arithmetic rounds rather than truncating, and at every scale from 96 to 192 DPI no control escapes the client area.

Three things follow from that and are worth stating plainly:

  • The process declares per-monitor DPI awareness v2 before it creates a window. Until DLP-470 it declared nothing, so Windows bitmap-stretched the whole dialog above 100% and a security prompt people are asked to read and answer was blurry on most current laptops.
  • The controls carry a font built at the current scale. Under per-monitor awareness Windows scales neither the font nor the child controls for you, and scaled controls around unscaled text is worse than not scaling at all.
  • The window's outer size is derived from the client area through AdjustWindowRectExForDpi, rather than the hard-coded 740x550 it used to be. Caption and border measurements vary with scale and with the user's theme, so a fixed outer size silently changed how much room the controls had.

And what to do instead (DLP-463) ​

The agent told somebody what was stopped and never what to do about it. A repository-wide search for safe alternative and approved channel returned nothing in code and nothing in the tracker's 346 tickets. In practice that means the operation gets repeated over a channel with no agent on it, and a blocked operation repeated somewhere unmonitored is a net loss.

NotificationConfig gains safe_alternative and support_url, and the notice renders them above the reference and the signpost - somebody who has just been stopped wants the way forward, and burying it under an incident id is how it goes unread.

Both ship empty, and that is the decision, not an omission. Naming a channel this organisation has not actually approved is the product telling somebody to use a route nobody sanctioned, which is worse than a notice that only says no. The field stays blank until somebody who knows the approved route writes one.

The alternative goes through the same fitness check as the policy's own sentence: it is written by the same author, for the same screen, and can name a detector by exactly the same accident.

The support link is validated, not sanitised. http and https only. A file: scheme would open something on the person's machine, javascript: would run in whatever opened it, and a UNC path reaches across the network - all from a link this product placed in a dialog somebody has been trained to trust. A tenant setting is not a trusted input: it is typed by an administrator, and an administrator whose console session was taken is exactly the threat. A refused link is left out, not shown broken: an empty Support: row would tell somebody help exists and give them nowhere to go.

The notice itself, in the reader's language (DLP-453) ​

Two of the three fixed sentences in a notice were English &'static str constants - the default message and DLP-135's clamped-action correction - so an Azerbaijani-speaking user read an English block notice unless the policy author happened to write Azerbaijani prose. A notice somebody dismisses without reading is a worse record than no notice at all, and the clamped correction is the half most worth understanding: it is what stops a person being told an operation was prevented where it was not.

Both are localisation::Key values now, in all four shipped languages, and the match is exhaustive per language - so a new notice string cannot be added without every language having one. The compiler enforces presence; a test enforces that the string is actually translated rather than the English pasted in, which compiles perfectly well.

The policy author's own sentence is never translated. It is the tenant's words, and inventing words on their behalf is not this product's job. The only thing done to it is refusal, if it names a detector.

The agent's UI is English (DLP-1897). The owner decided on 2026-10-07 that every agent surface is English, so AGENT_UI_LANGUAGE fixes it and the rule below applies only if that switch is set back to None.

Which language is the reader's (DLP-1135). The one the signed-in person's own session reports, when it is one of the four. Otherwise it is the tenant's chosen fallback (Settings → Warning language), and English only when the tenant chose none. Until DLP-1135 the service read the language with Locale::from_os() inside its own LocalSystem process, which returns the machine's language. So an employee who had set Russian on an Azerbaijani image was warned in Azerbaijani, and the fallback was a language nobody chose. The session agent runs as the person and now says their language when it connects. An older session agent says nothing, and then the tenant's fallback applies.

Since DLP-471 the dialog also grows to fit the text it was given. The four blocks that hold sentences - the message, the two field labels and the error line - are measured with DrawTextW(DT_CALCRECT | DT_WORDBREAK) using the font the dialog actually draws with, and layout::stack lays the rows out downwards from those measurements. Azerbaijani and Russian run roughly a third longer than the English the constants were drawn against, and a truncated this operation will not be prevented says the opposite of what it means, so the window gets taller rather than the sentence getting shorter. A block never shrinks below its design height, so a short translation leaves the dialog looking as it was drawn.

And it stops growing at the edge of the screen (DLP-480). Growth with no bound is its own defect: the message is the policy author's prose, of unbounded length, so on a 1366x768 laptop at 150% a long enough one put the submit and cancel buttons below the bottom edge, where no mouse reaches them. layout::stack_within clamps the laid-out dialog to the monitor's work area - not the screen, because the taskbar is exactly where those buttons would otherwise land.

Only the message gives ground. The labels and the error line are bounded by the string table; the combo box, the reason field and the buttons are what the person has to be able to use, so they keep their size and the message scrolls. It is never shrunk below about three lines, because past that the scrollbar is taller than the text it scrolls. A work area that could not be read clamps nothing, which is the same fail-open-on-availability rule the rest of the agent follows.

This is why the message block is a read-only edit rather than a static: a static cannot scroll. It also means the person can select and copy the message into a helpdesk ticket, which a static never allowed.

The measurement crosses two unit systems - Windows measures in the pixels of the display, stack reasons in design units - and a_measurement_survives_the_round_trip_through_pixels pins the conversion, because an error there is a dialog that grows slightly every time it is dragged between two monitors.

What none of this covers is whether the result is legible. No test here renders a glyph: the measurement is Windows's, and every assertion in this repository is arithmetic over the rectangles that come back. That the dialog is sharp, that the font is the right one, and that a Narrator user can work through it are all unobserved - DLP-472 is the theme and focus work, and DLP-478 is the matrix that would assert the rest.

6. Theme, contrast and focus (DLP-472) ​

One fact drives all of this and is the opposite of what most people assume: Windows dark mode does not change GetSysColor. A classic Win32 dialog that simply asks the system for its colours is correct in the default light theme, correct in every high-contrast theme, and a white rectangle at midnight for somebody running dark mode. So a dark dialog has to name its own colours.

And there is one rule that overrides everything else: a high-contrast theme is never overridden. Somebody running high contrast has usually configured it because they cannot read the default, and an application that substitutes its own idea of dark is not a nicer dark - it is unreadable for precisely the people who asked not to be guessed at. session_ui::theme::appearance is a three-line function and that rule is the whole of it; the_dark_palette_is_unreachable_while_high_contrast_is_active walks all six combinations of preference and contrast to hold it.

An unreadable preference is None, not a default. "The registry did not answer" and "the person chose light" are different facts, and only one of them is a reason to paint light on purpose.

Focus rectangles are forced visible with WM_CHANGEUISTATE. Windows hides them until somebody presses a key, and a person working this dialog entirely from the keyboard needs to see where they are from the first frame rather than the second.

What is not covered: nothing here has been seen by a person or a screen reader. The appearance decision is asserted; the painting is not, because asserting it would mean rendering. Whether Narrator announces every control, whether the contrast ratios pass at each theme, and whether the focus ring is visible against the dark palette are all unobserved - DLP-478 is the matrix that would close that gap, and DLP-1176 is the interactive session it needs.

7. Which surface a notice appears on (DLP-467) ​

Until now every user-visible notice was modal: a message box raised by WTSSendMessageW from the service, or MessageBoxW from the session process. That is the right shape for you are being stopped and the wrong shape for this happened, carry on. A modal takes focus from whatever somebody is typing into, never reaches Action Center, ignores Focus Assist, and carries no application identity - so the first thing a person learns about this software is that it interrupted them.

session_link::transport(urgency, toast_available) decides, and it is three cases:

UrgencyToast availableSurface
Blockingeithermodal
Informationalyesnative toast
Informationalnomodal

A blocking notice is never a toast. A notification somebody can leave unread in Action Center is the wrong shape for a decision that is holding their work open. And a toast that could not be raised falls back to the modal rather than disappearing: a notice nobody saw is indistinguishable from a notice nobody sent.

Urgency travels on ToSession::Notice with #[serde(default)], so a service and a session process at different versions still understand each other and an older service's notices are treated as informational - the gentler of the two.

The producer that was missing ​

ToSession::Notice had been in the protocol with both session clients handling it and nothing anywhere sending one. The surface was unreachable, which reads like a feature and is not one - the same shape as the prompt round-trip DLP-113 found and the unproduced channels DLP-132 found. PromptDesk::notify_from_with_receipt is the producer, and enforce's warn arm sends only to the OS session attributed to the event. It does not fall back to the active console: on a multi-session host that would warn a different person. A file event, which Windows attributes to no session, goes to the sole signed-in session when exactly one person is signed in and that session is active: to its session agent, or with none attached, to the service's own box in that session (DLP-1125). Otherwise, with no attributed, attached session, the event records not_delivered (DLP-1127).

A warning counts as shown only when the session says so (DLP-1315) ​

The warn arm used to return applied as soon as the pipe to the session agent accepted the notice. On a physical PC a warning was recorded as shown while the person watching the screen saw nothing, and the count of warnings nobody saw (DLP-1276) could not see it either.

Now the service sends the notice with an id and waits up to four seconds (NOTICE_RECEIPT_WAIT) for FromSession::NoticeShown. The session decides where the notice goes (session_ui::notice_route), reports what it did, and only then opens a dialog, so the service never waits for somebody to close a box:

Session reportsRecorded asService does
toastappliednothing more
dialogappliednothing more
withheld (full screen, presenting, quiet time)not deliverednothing more: its own box would open over the projector
nothing within the wait, or the session went awaynot deliveredno fallback to a guessed console session (DLP-1127)

A toast counts when the notifier's Setting is Enabled, or has not been written yet (DLP-2064), and Show succeeds. Windows can still refuse a toast after Show, through its Failed event. The session agent watches that event for 1.5 seconds afterwards without holding the receipt (DLP-2076), and if it fires, it shows the dialog itself and logs it. That one notice was then recorded as a toast and seen as a dialog. A notice Windows refuses is never silent. Even an accepted toast is not proof that a person looked at it; nothing on a desktop can report that.

A session agent from before receipts answers nothing, so no warning is counted as shown. The silence optimization is per connection: after one unanswered wait that route reports unresponsive until its own late receipt or reconnect, never silencing another session. Withheld or unconfirmed notices do not consume that session's content-hash burst window. The key also includes the connection generation: reconnecting cannot inherit a previous connection's warning, and a queued report from the retired connection cannot be delivered to its replacement. The service never substitutes an active console for a missing origin or receipt (DLP-1127).

Not established: why the toast did not appear on the PC that found this. The next run there will say, because a refused toast now becomes a dialog.

The toast payload ​

toast::payload builds the XML and lives outside cfg(windows) so the escaping is asserted everywhere. That matters: the title carries a file name and the body carries the policy author's prose, both arrive from outside, and a message containing </text> pasted into the template unescaped would be an injection into the shell's own notification parser.

The toast is a plain ToastGeneric with two text lines and no buttons - an action the person could click would need a handler in a process that may have exited by the time they clicked it. scenario is deliberately absent: reminder and alarm keep a toast on screen until dismissed, which would turn the one surface that does not interrupt into one that does.

The app id is registered under HKCU\Software\Classes\AppUserModelId, which gives the toast its name and mark. The Start Menu shortcut also carries the id as System.AppUserModel.ID (DLP-2064).

The MSI sets that property with ShortcutProperty. The console install sets it on the link it wrote, through IShellLinkW and IPropertyStore.

On both physical hosts, every informational notice fell back to a modal dialog. The shortcut was not the cause. The notifier's setting answers "Element not found" (0x80070490) for any app id that has never shown a toast, built-in ones included, and Windows writes the setting on the first toast. Refusing on that answer meant the first toast never came. That answer now lets the toast be tried, and Windows' Failed event still turns a dropped toast into a dialog. A setting that reads disabled still falls back. No toast has been shown on a host yet.

Not over a projector (DLP-468) ​

A modal raised by the service lands on top of whatever is on screen. In a government body that includes a full-screen presentation and a screen-shared call, so a dialog naming a file becomes an information-disclosure event in front of a room - and the fastest way to teach somebody to dismiss this agent's dialogs without reading them.

Before showing one, the agent asks the shell what the session is doing (SHQueryUserNotificationState) and presentation::may_show decides. The answer describes the caller's session, so it counts only from a process in the person's session (presentation::state_in, DLP-1251). The service runs in session 0, which has no shell and no person. Until that fix it read session 0's answer, never got Accepts, and withheld every fallback dialog to a signed-in person on the first physical host. From session 0 the state is now Unknown, and it shows. The projector guard therefore no longer applies to the service's own message box, which is used only when no session agent is connected. The session agent's toast is subject to Focus Assist, which Windows applies itself.

  • A blocking notice is always shown. Somebody whose operation was refused needs to know now; deferring it leaves them looking at a file that did not copy with no explanation.
  • An informational notice waits while anything is full screen, in presentation mode, in quiet hours, or while the machine is locked.
  • An unreadable state shows. The query failing is an availability problem, and CLAUDE.md §7 is explicit that this product fails open on availability - a notice withheld because we could not ask is a notice nobody ever gets.

The event is queued either way. Only the dialog is withheld, and NotifyOutcome::Deferred carries the state that caused it, so we chose not to interrupt a presentation is distinguishable in the record from the dialog broke. That distinction is the one an auditor asks about first.

What this does not do is re-raise the notice when the state clears. There is no timer and no watcher: the agent is event-driven, and adding a polling thread to re-check the shell would be a permanent cost for a rare case. A deferred notice is recorded as not shown and the next event that warns is shown normally. If that proves too quiet in the field, a watcher is the fix and it is a separate decision.

What an answer actually buys (DLP-457) ​

Answering a justification prompt used to buy an unbounded proceed. There was no grant object, so nothing could be scoped to one operation, nothing expired, nothing was counted per person, and nothing could be refused because the endpoint was offline. A question with no consequence attached is not a control; it is a delay somebody learns to type through.

GrantConfig on the policy answers four things, and every default is the strict one - a policy author who does not think about this gets the narrowest grant, not the widest:

SettingDefaultWhy
durationsingle_operationConsumed by the act it authorised
max_per_daynoneUnlimited is what every policy did before; it is not the default anybody should choose, but making it the enforced default would change behaviour nobody asked to change
allow_offlinefalseThe offline_response precedent

A single-operation grant never covers a later operation. That is the whole of what single means, and before this the coalescing window alone decided - so every answer bought the same unbounded proceed regardless of what the rule asked for.

Offline is refused unless the policy says otherwise. An endpoint that cannot be observed is one whose grants nobody can review until it reconnects, and a control that relaxes itself exactly when it cannot be watched is the wrong way round. When both a daily limit and the offline rule would refuse, offline is reported: it is the one the person can act on.

A bundle change invalidates an outstanding grant. Somebody answered a question about a rule that no longer exists in that form, and carrying the answer forward would apply consent they never gave to a rule they never saw. Any difference counts, including a rollback - this endpoint cannot tell a loosening from a tightening, and guessing in the permissive direction is the one mistake that matters.

What this does not do. While no enforcer exists (#387, #390) a grant gates nothing: the operation proceeds either way and the grant is a record of what somebody was told they could do. It must still be correct, because the day an enforcer arrives this becomes the thing standing between a person and a refusal. And the daily count lives in memory, so a restart clears it - stated in the code as a limit rather than left to be discovered.

A rule that cannot be overridden (DLP-458) ​

There was no way to mark a rule non-overridable, and the thing that decided whether somebody could proceed was !capability.can_block - a property of the endpoint, not of the rule. On every platform this product currently ships on, nothing can block. So every rule permitted proceeding, including the ones about private keys.

NotificationConfig::no_override is the rule's own decision. justification::permission(no_override, can_block) combines it with what the platform can do, and it is a pure function because the whole of it is a security decision that should be assertable without a runner or a capability matrix.

The prompt now has three sentences, not two. "You may not" and "we could not stop you" are different facts, and somebody deciding what to do next needs the right one. Where the platform cannot actually prevent the operation the notice still says it was recorded rather than prevented - a rule that refuses and an endpoint that cannot block are two separate things and the person is told both.

Both prompts choose through outcome_key. The console prompt used to carry its own hard-coded English copy and had already fallen behind - it knew two outcomes where the dialog knew three, so a rule that may never be overridden read there as one the platform merely could not enforce. A second list of the same thing is a surface that passes while saying less than it claims.

What is not decided here is which categories an organisation marks non-overridable. That is a stakeholder decision - recorded as question 1 in the analysis report - and the field defaults to false, so a bundle written before it existed behaves exactly as it did.

The reasons people actually have (DLP-464) ​

The justification vocabulary had six categories and the target model asks for nine. The three missing ones were not obscure - the information is already public, a manager has approved this, and the agent is wrong about this file - they are three of the reasons a person is most likely to genuinely have.

The third is the one that matters. There was no false-positive route at all. Somebody who knew the agent was wrong had no way to say so, and the nearest option was to pick a reason that was not true - which is worse than no category, because it records a wrong answer as a right one. It is also the cheapest detector-tuning signal there is, and its absence is why precision work has been guesswork.

Saying the agent is wrong changes nothing about what happens. The operation is handled exactly as the policy says; the claim is a flag on the incident and nothing more. A false-positive button that let somebody through would be a one-click bypass with a reasonable-sounding name.

A manager has approved this is likewise a claim, not a check. Nothing verifies that a manager approved anything; it records what the person said, which is what makes it answerable afterwards. Verifying it is what an approval workflow is for, and conflating the two would let a typed sentence stand in for an authorisation.

7a. One notice per burst (DLP-456) ​

A warn policy matching a folder copy raised a modal per file. Fifty modal dialogs is not a control anybody lives with - it is the fastest way to get this agent uninstalled, and it was the most likely field failure in the whole notification path.

The justification path had solved this since #471: justification::Coalescer keys an answer on (policy, destination) so one decision covers a burst. The warn path called warn_user directly and consulted nothing. notify::burst::Burst is the same idea for a notice, which needs less - there is no answer to remember, only the fact that somebody has already been told.

Keyed the same way, and for the same reason: somebody copying a folder to one place made one decision about that place. Keying on the file would show fifty dialogs; keying on the policy alone would let a notice about a USB stick cover an upload to a personal cloud account, which is a different decision entirely.

What the key was, and is (DLP-2060). Until DLP-2060 the paragraph above described an intention. The runner passed the file's own path, and from DLP-1363 its content, so every distinct file was a fresh notice: on a physical host forty distinct files in one folder raised forty dialogs in about thirty seconds. A notice is now covered by either of two subjects:

  • the file's content, so a rename or a move of a file just warned about is not warned about again (DLP-1363);
  • the folder the file is in, on its channel. The local file channels count as one, so a copy's created and modified files share the notice, and a copy to a USB stick stays apart from an upload.

A notice covered by its content records nothing about the folder it moved to. The next file there is still shown, because no notice named that folder.

Suppression is of the dialog, never of the event. Every operation is still queued and still recorded, and the debug record names which notice covered it and how long ago - so the trail shows one decision and fifty acts rather than fifty decisions nobody made.

The window expires. One that did not would silence a policy for the life of the process, which is a worse failure than the storm it prevents.

Legacy service boxes (DLP-1379, superseded on Windows by DLP-1127) ​

Windows no longer uses this service-box fallback for policy warnings or folded summaries: the batch carries no safely attributable recipient. The gate remains for the frozen Unix path. The following describes the former Windows fallback, not current per-session delivery.

The burst window covers one decision about one place. It does not cover warnings about different files, rules or places that arrive while the service's own box (WTSSendMessageW, the fallback when the session agent does not confirm) is still on screen. Windows queues those: each box opens when the previous one's 60 seconds run out. On a physical host two warnings ten seconds apart opened 72 and 123 seconds after they were logged as shown.

notify::BoxGate allows one box per 60-second window. A warning that arrives inside the window is not sent. It is recorded as not delivered, because nobody was told at that moment, and counted. The next box says how many were counted: the next warning's own box, or a single summary once the window has passed and nothing else came. A box that could not be shown releases the window and keeps its count.

Not established: whether the person clicked a box away early. The service does not wait for the box, so the window is always the whole 60 seconds.

7b. Asking for an exception (DLP-460) ​

A person facing a refusal had no way to ask for it to be lifted. The only escape from a policy was a local justification that proceeded anyway, which is not an exception - it is the absence of one. Every exception that happens informally is one nobody can count, bound, expire or review.

A request carries the hash, never the file. An approver decides whether this content may go to that destination; they do not need the bytes, and a request carrying them would put the sensitive value in a second store with a different access path. For the same reason the file name travels in its masked form (DLP-454) - the unmasked one is already on the incident, where access is controlled.

It is signed by the device key like every other agent request, so a local process cannot manufacture one on somebody else's behalf.

The request is queued, not posted. Two reasons. The justification path is synchronous, and an HTTP round trip there would block a file operation on a network call. And somebody on a train who asks for an exception has asked for it - holding the request until the link returns is what they expect, and losing it because the endpoint happened to be offline is what teaches them not to bother. It goes out on the delivery cycle, which is the thing that already knows whether the server is reachable, and a failure leaves it in the queue for the next attempt.

The queue is bounded at 64. A machine that cannot reach its server for a week must not accumulate requests until it runs out of memory, and the oldest are the ones most likely already resolved another way.

An inherited answer raises nothing. It covered an earlier operation and its request went out then; a second one for the same decision is a duplicate an approver has to reconcile by hand. Nor does an operation that was allowed - raising a request for something that already went through fills a queue with decisions nobody needs.

Rate-limited per device, five an hour. A request path easier than compliance becomes the default route, and an approver queue nobody can keep up with stops being read - which fails every request in it, including the real ones. An unusable reason is refused before the rate limit is consulted, because "write a reason somebody can act on" is the one the person can do something about.

pending permits nothing. An endpoint waiting for an answer has not been given one, and a product that let the operation through while a request was outstanding would make asking the fastest way to proceed.

expired is its own state, not a rejection. We said no and nobody looked are different facts about an organisation, and only one of them is about the request. An SLA that expired quietly into rejected would hide a failure in the process behind a decision nobody made.

8. Am I protected right now? (DLP-474) ​

The agent already held every fact needed to answer this and none of them reached the person. The local page (dlp-agent ui) showed identity, capabilities and history, and nothing about whether protection was actually in force - so somebody who lost their network had no way to tell the agent cannot reach the server from the agent has stopped protecting me. Those are very different facts about their machine, and the second one is the one people assume.

The page now leads with a Protection section: a status word, a sentence, the last policy update and whether the server is reachable. ui::protection_status decides, and two rules are the whole of it:

  • An unreachable server is never reported as unprotected. The status reads Protected - cannot reach the server, and the sentence under it says the last published policy still applies and that protection has not stopped.
  • An unreadable state is never reported as protected. It reads Unknown, and says so is a fault worth reporting rather than the same thing as being switched off. That rule outranks every other fact, including a healthy-looking one.

Status is a word, not a colour. A coloured dot alone is invisible to a colour-blind reader and in a high-contrast theme, and this row is the one thing on the page somebody came to read.

What the page still will not say ​

SPIKE-018 §3 applies here as much as it does to the tray: a surface whose state varies with what is armed becomes a monitoring-status oracle, and tells a departing employee exactly when they are being watched. So the page says whether the agent is protecting, and never what it is looking for. no_status_tells_the_person_what_is_being_watched asserts that no status word or sentence contains rule, detector, policy name, matched, blocked or scanned.

Where the reachability fact comes from ​

self.online in the runner dies with that process, and dlp-agent ui is a different, short-lived one. So Runner::deliver writes the time of each successful contact to last-server-contact in the state directory, and server_contact::reachability turns it into an answer. It is generous on purpose - an hour, four times the default check-in - because an endpoint one poll late is not offline, and a page that cried wolf would be ignored the once it mattered. A contact timestamp in the future counts as reachable: that means the machine's clock moved, not that the server went away.

The file holds a timestamp and nothing else, asserted, because a fuller record of when a machine talked to its server is activity data that nothing here needs.

The service's check-in loop writes the same file on each check-in the server answers, and since DLP-1310 a failed check-in or delivery writes last-server-failure beside it: the time and the first line of the transport error (at most 300 characters, about the network and the server, never about a file). The next contact removes it. dlp-agent doctor reads both as "agent's server connection" and reports its own probe separately as "server reachable from doctor", because the two can disagree: on a PC whose DNS failed on and off after a resume, doctor's own check-in got through while the service's queue grew. Only the run loop and delivery write them, so doctor's probe (since DLP-1311 a signed POST /api/v1/agent/probe, not a check-in) is never recorded as the agent's.

9. What is not covered ​

  • Linux remains console-only. Windows justification prompts are native standard Win32 controls (agent/core/src/session_ui.rs) with keyboard tab order and screen-reader metadata, and the session pipe now receives prompts and returns the validated answer. Linux's dlp-agent session remains the explicit console implementation.
  • No Linux warning at all. DLP-194.
  • Interactive accessibility has not been exercised in CI. The Windows dialog uses standard controls (the supported MSAA/UI Automation path), but this repository has no hosted screen-reader or desktop-session runner. Windows GNU compilation and the pure justification tests are the automated checks; RDP, Fast User Switching and logoff-mid-prompt remain deployment checks.
  • dlp-agent ui has never been run with a person at a keyboard either. The page is asserted as a string and rendered to check it, on Linux. Whether cmd /c start opens it on a real Windows desktop, and whether xdg-open does on a session with no default browser, has not been observed - the command prints the path and does not treat a failed open as an error, which bounds the damage and does not test it.
  • Nothing tells the person the page exists. It is a command somebody has to know to run. A warning dialog that named it, or a shortcut the installer writes, is the obvious next step and is not built.
  • The correction in §1 is not shown in the browser path. The extension receives message from the upload verdict and renders it; the clamp note is applied in enforce, which the browser path does not use. Recorded rather than fixed blind: the browser's own clamping tests (a_block_on_an_endpoint_that_cannot_block_does_not_become_one_here) show the action is clamped correctly, and whether its message should carry the same sentence belongs with DLP-271's extension work, where somebody can test what a person actually sees.

What a warning shows, in words people use (DLP-1265) ​

The first physical host's owner found the notice unfriendly. Their screenshots showed three problems, now fixed:

  • The name the File row masked was printed in full under Destination. A file saved in place has no destination but itself, and its full path went under "Destination": File: T01-payment-card-***-***.txt sat over Destination: C:\MyDLP-Test\T01-payment-card-20260928-221244.txt. Such a file now shows Saved to and its folder. A copy to a stick shows the drive or folder it landed in. A destination elsewhere that names the file gets the File row's mask. notify::destination_row decides, and is tested. An existing test had been asserting the unmasked name, which only the leak could satisfy; it now asserts the opposite.
  • The correction when a block could only be recorded is the sentence above, in all four languages.
  • The last line named a command a person never types (dlp-agent ui). It now says where to click: What this software is, what it records and how to remove it: Start menu → "cyberkap - About this agent". (named MyDLP until DLP-1856) It still carries all three things DLP-293 requires.

Not changed: the default sentence when the policy author wrote nothing. A connected session agent shows the toast. If the event has no exact connected session route, no UI is shown and the event says so, except that a file event on a machine with exactly one person signed in, active, is warned in that session (DLP-1125); the agent never guesses the active console (DLP-1127).

Statements here describe what is built and tested; planned work is marked as planned.