Appearance
Local IPC, claim by claim
WIN-DLP-EPIC-023 asks to authenticate every local IPC channel and defend it against a hostile local process, because the service is privileged and the session process is not, and that boundary is the one a local attacker will push on.
It is the best-defended part of this product - 59 tests on the channel alone - and the defences are the right ones rather than a checklist. The audit's finding is not a hole in them: it is that the Windows half of those defences was tested only in a Windows CI job that has not yet passed on windows-latest (Windows CI status).
Implementation: agent/core/src/session_link.rs, with unix.rs and windows_impl.rs.
1. Identity comes from the kernel
The single most important property, and the one an implementation usually gets wrong by being polite.
| Claim | Proved by |
|---|---|
| The identity comes from the kernel and not from the peer | session_link::unix::tests::the_identity_comes_from_the_kernel_and_not_from_the_peer |
The claimed_user field is documented as advisory | session_link::tests::the_claimed_user_is_documented_as_advisory |
| An unresolvable uid is still an identity | session_link::unix::tests::an_unresolvable_uid_is_still_an_identity |
The peer does send a claimed_user, and the agent compares it - not to trust it. On Unix, a failed peer-credential lookup refuses the connection, and a non-empty claim that contradicts the kernel's username is refused before a welcome. An unresolvable uid is retained as uid:<n>, which is still the kernel's answer. Windows applies the same fail-closed rule after pipe impersonation.
2. Who may connect at all
| Claim | Proved by |
|---|---|
The socket lives with the agent's own state, not in /tmp | session_link::unix::tests::the_socket_lives_with_the_agents_own_state_not_in_tmp |
| It can be opened by the person whose session agent needs it | session_link::unix::tests::the_socket_can_be_opened_by_the_person_whose_session_agent_needs_it |
| A socket tightened beyond that is reported, not left looking like a bug | session_link::unix::tests::a_tightened_socket_is_reported_rather_than_left_to_look_like_a_bug |
| A stale socket from a killed run does not stop the next one | session_link::unix::tests::a_stale_socket_from_a_killed_run_does_not_stop_the_next_one |
| A mismatched Unix identity is refused before a welcome | session_link::service::tests::a_mismatched_session_claim_is_refused_before_welcome |
/tmp is the wrong place for this and the test says so by name: a world-writable directory means a local process can sit on the path before the agent does.
On Windows, the same question is answered by a protected DACL and by FILE_FLAG_FIRST_PIPE_INSTANCE:
without it, anything that created a pipe of the same name earlier would own the name and the session agent would connect to it instead. With it, a second creator fails and the agent finds out rather than talking to an impostor.
…and the flag is deliberately not set on later instances, because by then the agent is itself the earlier creator. That distinction is the kind of detail this attack usually wins on.
| Claim | Proved by |
|---|---|
| The DACL names SYSTEM, administrators and interactive users, and nobody else | session_link::pipe_acl_tests::the_dacl_names_the_system_administrators_and_interactive_users_and_nobody_else |
| No deny entry for administrators | same test |
| The pipe is in the local namespace, not reachable over the network | session_link::pipe_acl_tests::the_pipe_is_in_the_local_namespace |
| A second pipe instance accepts while the first session remains connected | session_link::windows_impl::tests::a_second_instance_accepts_while_the_first_session_stays_connected |
| A notice or prompt reaches only the kernel-attributed OS session, and an unattributed event reaches none | session_link::prompt_desk_tests::routing_never_guesses_a_console_session; a_prompt_and_notice_reach_only_the_os_session_the_event_names; an_event_without_a_session_is_sent_to_no_windows_session |
The ACL and namespace assertions run everywhere (DLP-137). They were inside #[cfg(windows)] mod windows_impl, so they executed only in the windows-agent CI job, which has not yet passed on windows-latest (Windows CI status). They are assertions about a string: checking who may open a pipe needs no Windows. The constant and the tests moved to the portable module, for the same reason platform.rs gives about its own arms - the arm nobody runs is the arm this catches. The concurrent-instance test does exercise a real named pipe and therefore remains Windows-only.
The "no deny entry" assertion is worth reading twice. It is not an oversight that administrators are allowed: a channel an administrator cannot inspect or remove is indistinguishable from malware, which is the same principle the self-protection epic is built on.
3. What a connected peer may do
| Claim | Proved by |
|---|---|
| A peer that does not say hello first is dropped | session_link::service::tests::a_peer_that_does_not_say_hello_first_is_dropped |
| A peer that greets with neither kind is dropped | session_link::service::tests::a_peer_that_greets_with_neither_is_dropped |
| A session agent cannot reach the upload path | session_link::service::tests::a_session_agent_cannot_reach_the_upload_path |
| On frozen Unix, a second session is still idle-only | session_link::service::tests::a_second_session_agent_reports_idle_but_not_clipboard; an_idle_only_session_cannot_answer_the_primary_sessions_prompt (Unix-only) |
| Windows reports, prompts and receipts bind to the kernel OS session and current connection, never a retired reader | session_link::windows_impl::tests::the_windows_server_binds_reports_answers_and_receipts_to_the_kernel_session |
| Another OS session cannot answer or cancel a prompt, or acknowledge its notice | session_link::prompt_desk_tests::a_prompt_and_notice_reach_only_the_os_session_the_event_names; session_link::notice_receipt_tests::a_receipt_cannot_cross_sessions_or_attachment_generations |
| One unresponsive Windows session does not silence another | session_link::notice_receipt_tests::one_silent_session_does_not_silence_another_and_only_its_own_receipt_resets_it |
| A browser host and a session agent are served at the same time | session_link::service::tests::a_browser_host_and_a_session_agent_are_served_at_the_same_time |
| A clipboard is dropped when the tenant switched it off | session_link::service::tests::a_clipboard_is_dropped_when_the_tenant_switched_it_off |
The third row is the one that matters most. Two kinds of peer share one socket, and a peer that greeted as a session agent and then sent a browser message would be reaching a path it never declared itself for - so the connection is dropped rather than served both ways. Role is decided at the greeting and does not change.
The sixth is the same instinct applied to policy: the switch is the tenant's, and a session agent that ignores its Welcome and reports a clipboard anyway is not trusted to honour it.
Idle reports are narrower still: the session agent sends only elapsed seconds, never input, window, process or text. The service keys the sample to the kernel-authenticated peer, expires it after 90 seconds without a refresh, and uses the most active live session when several are connected. A missing sample is unknown and therefore never a reason to pause a walk. Windows creates a named-pipe instance per connection and serves each on its own thread. Every authenticated Windows session may report its own clipboard and receives only its own prompts/notices. Reconnection retires that session's old questions and receipts; an old reader cannot answer a replacement's prompt or receipt, and an old disconnect cannot remove its route. The Unix agent retains its existing single-primary/idle-only behaviour. OS session IDs remain in memory, not logs. Queued clipboard reports also carry the originating connection generation: an OS session ID reused after logoff does not retarget an old warning to a new connection. a_queued_event_from_a_retired_connection_does_not_reach_its_replacement and a_reused_os_session_id_does_not_inherit_the_previous_connections_warning cover the delivery and suppression sides respectively.
4. What a hostile peer cannot do
| Claim | Proved by |
|---|---|
| A peer that never sends a newline cannot grow the agent until it dies | session_link::unix::tests::a_peer_that_never_sends_a_newline_cannot_grow_the_agent_until_it_dies |
| A message is refused on length before it is parsed | session_link::tests::a_message_is_refused_on_length_before_it_is_parsed |
| Rubbish ends the connection rather than the agent | session_link::service::tests::rubbish_ends_the_connection_rather_than_the_agent |
| Rubbish is refused with a reason rather than panicking | session_link::tests::rubbish_is_refused_with_a_reason_rather_than_panicking |
| Content that is not base64 does not reach the scanner | session_link::service::tests::content_that_is_not_base64_does_not_reach_the_scanner |
| A channel that could not be opened is not fatal, and says why | session_link::service::tests::a_channel_that_could_not_be_opened_is_not_fatal_and_says_why |
| An answer to a question nobody asked is dropped | session_link::prompt_desk_tests::an_answer_to_a_question_nobody_asked_is_dropped |
| A killed session agent cannot be mistaken for one that stayed silent | session_link::prompt_desk_tests::a_session_agent_killed_mid_question_cannot_ask_rather_than_no_answer |
| A refused connection is said, by class and never by content, at most one line in ten seconds | session_link::tests::a_refused_connection_names_its_class_and_never_its_content, refused_connections_are_said_at_most_every_ten_seconds |
| Every refusal is counted, said or not, and the counts reach the console | session_link::tests::refusals_are_counted_by_class_and_none_is_no_report; console endpoint-detail.test.tsx "refused session-channel connections" |
Bounding before parsing is the difference between a limit and a denial-of-service: an unbounded read from an unprivileged local process is the cheapest attack on a privileged service there is.
A probe of the pipe used to leave no trace (DLP-1689): a connection that closed early, sent rubbish or opened with anything but a greeting was dropped without a log line. Each is now one rate-limited line naming the class and the peer's process and session, and since DLP-1706 every refusal, including a greeting that names another user and a peer Windows cannot identify, is counted from the agent's start and carried in the health report. The endpoint page shows "Session channel: N connections refused since the agent started" with the count for each reason. A restart starts the count again.
The last two are from DLP-113, and they are about a peer that is hostile by being absent: a compromised session agent can answer what it was asked and cannot conjure a question, and killing it produces "nobody could be asked" rather than silence that reads as consent.
5. What is absent
| Capability | State |
|---|---|
| A Windows pipe-squatting and impersonation review | Absent. DLP-197 |
| A test that actually squats the pipe | Absent. DLP-215 |
FILE_FLAG_FIRST_PIPE_INSTANCE is the defence and it is applied; what has never happened is somebody creating that pipe first and watching the agent refuse to start rather than talk to them.
6. What is not covered
- The pipe round-trip and concurrent-instance tests still need Windows, and legitimately: real kernel objects cannot be faked on Linux. Those tests stay in
windows_impland run in thewindows-agentjob. - The hostile-client cases run in CI, not on a managed fleet.
DLP-215has a standard user squat the pipe name and verifies that both service and session agent refuse it; a real enterprise endpoint stack has not been part of that exercise. - The browser host's allowlist still needs an external identity. Host registration and policy templates exist (
DLP-236), but a signed, published extension id needs the organisation's store account and signing process (DLP-1203).only_the_allowlisted_extension_is_servedproves the mechanism, not that external deployment.