Endpoint Security on macOS without the entitlement: eslogger and Swift

Reading eslogger from a root Swift helper: Full Disk Access, a one-pass Aho–Corasick prefilter on raw JSON, rules scoped to the program, and self-noise.

· Published · 8 min read · Versions: macOS 26, Swift 6 · About the project →

Endpoint Security is the macOS API that EDR products use to see every process launch, login and permission change. Using it directly requires an entitlement that Apple grants case by case. This post shows how MacSentry, my open-source Mac monitor, gets the same events through /usr/bin/eslogger instead, and what it took to make that fast and quiet: reading JSON Lines without parsing most of them, matching rules against the right program, and not alerting on itself.

eslogger gives you Endpoint Security events without the entitlement

A native Endpoint Security client needs the com.apple.developer.endpoint-security.client entitlement, and Apple's documentation says plainly that you must request it. For a personal tool built from source, that is not realistic.

macOS ships eslogger, a command-line Endpoint Security client that prints events as JSON Lines. MacSentry's helper, macsentry-es, is a root LaunchDaemon that starts eslogger with a list of event names and reads its standard output. The eslogger(1) man page sets the terms, and each one shaped the design:

  • It must run as root, and the responsible process needs Full Disk Access. Because the helper launches eslogger as a child, macOS checks the helper's binary, so Full Disk Access goes to /usr/local/libexec/macsentry-es, not to eslogger.
  • It supports notify events only, never auth events. You can see that something happened, but you cannot block it. That matches what MacSentry is: a tripwire that reports, not a shield.
  • "eslogger is NOT API in any sense." The output format may change between macOS releases without warning. The helper therefore saves the first raw sample of every event type to disk, and a small script prints their structure, so when a field moves I can see where it went instead of guessing. Field access is tolerant too: the failure reason of a sudo event, for example, is looked up in more than one place.
  • It suppresses events from its own process group, to avoid feedback loops. That covers the helper itself, but not the separate root daemon, which comes back below.

The helper subscribes to around twenty events: authentication, tcc_modify, exec, sudo, su, openssh_login, screensharing_attach, XProtect and Gatekeeper events, Background Task Management (btm_launch_item_add), configuration profiles and Open Directory changes (new users, admin group edits). eslogger --list-events prints what your macOS version supports.

A side benefit: sudo, su and openssh_login replaced a log stream reader that cost about 5% of one core. The daemon now starts log stream only as a fallback when the helper is down; how the rest of the daemon moved from polling to events is in rewriting MacSentry's daemon from Bash to Swift.

Pipeline of the Endpoint Security helper: eslogger prints JSON Lines, getline reads them into one reused buffer, an Aho–Corasick prefilter on the raw bytes drops most exec lines, JSONSerialization parses the rest, rules are matched against the executable, the helper skips its own commands and allowlisted ones, writes findings to a spool with an atomic rename, and the daemon sends the alert.eslogger (root)Endpoint Security events as JSON Linesstdoutgetline(3)one reused buffer, no allocation per linePrefilter on the raw bytesone Aho–Corasick pass over all rule needlesmost exec lines are dropped hereJSONSerializationonly for the lines that can matchRules scoped to the executabledata carriers such as curl are never evaluatedSkip own commands, apply allowlistparent chain, marker file, macsentry.allowSpool: write .tmp, rename to .msgthe helper never holds a tokenDaemon sends the alert
Each step is cheaper than the next, so most events are dropped before anything is parsed or allocated.

Read JSON Lines with getline and skip the parser for most lines

Almost all the volume is exec. Every program launch becomes a line that carries the full argument list and environment, often around 4 KB, and a development machine launches a lot of programs. Only a tiny fraction of them can match a rule, so the goal is to reject the rest before allocating anything.

Two changes did most of the work:

  1. Read lines with getline(3). It buffers in user space and reuses one buffer for every line. The obvious Swift approaches allocate or copy per line: FileHandle.availableData plus removing the consumed prefix from a Data buffer copies the remainder each time, and readLine() creates a String for every line.
  2. Prefilter exec lines on the raw bytes. Each rule declares needles, strings the raw line must contain for the rule to possibly match. All needles go into one Aho–Corasick automaton, built as a case-insensitive DFA, so a single pass over the line checks all of them at once instead of calling memmem once per needle. Lines with no needle are dropped without ever reaching JSONSerialization.
func execIrrelevant(_ hay: UnsafeRawBufferPointer) -> Bool {
    let isExec = execMarker.withUnsafeBytes { memmem(hay.baseAddress, hay.count, $0.baseAddress, $0.count) != nil }
    return isExec && !needleMatcher.matches(hay)
}

One detail is easy to miss: JSON may escape / as \/, so a needle like /dev/tcp/ is added in both forms.

With both changes, replaying 100,000 recorded events through the helper took 0.77 s instead of 8.7 s. The replay mode (macsentry-es --replay < events.jsonl) needs no root and sends nothing, which made that measurement easy to repeat.

Bar chart of the time to replay 100,000 recorded events: 8.7 seconds before, 0.77 seconds with getline and the prefilter.Before8.7 sgetline + prefilter0.77 s
Replaying 100,000 recorded events: about eleven times faster.

Even so, exec monitoring is the most expensive part of MacSentry, because eslogger itself has to serialize every launch. On a busy development Mac the helper and eslogger together use about 2% of one core, so the installer asks whether to enable it and ES_EXEC=0 turns it off.

Match rules against the program that runs, not the text

A regular expression over the whole command line cannot tell a command from data that merely mentions it. MacSentry's own early shell version shows the problem: it sent Telegram messages with curl, so an alert describing a keychain read put the text security find-generic-password into curl's arguments, where a command-line rule would see it and fire again.

The fix is to give each rule the set of programs it applies to (from es/Rules.swift, with the alert title translated):

rule("🔑 Keychain password read", .alert, ["security"],
     #"(^|/)security\s+(dump-keychain|(find-generic-password|find-internet-password)\b.*\s-[wg]\b|export\b)"#)

The keychain rule is only evaluated when the executable is security. Reverse shell patterns only apply to shells and interpreters (bash, nc, socat, python, osascript...). A separate list of data carriers (curl, wget, echo, grep, git...) is never evaluated at all, because their arguments are data, not commands. The same set of programs also provides the default needles for the prefilter above, so both layers agree on what a rule is about.

Legitimate tools still trip rules. Claude Code, for example, reads its own login token from the keychain with security find-generic-password. Rather than weakening the rule, /etc/macsentry.allow holds regular expressions matched against "<command> || <parent chain>", so an exception can name both the command and the program that ran it. Allowed matches are logged at most once an hour, since they tend to repeat constantly.

Don't alert on your own commands

The MacSentry daemon runs a few commands that the rules would flag, and because it is a separate LaunchDaemon, eslogger's process-group suppression does not hide them. Two techniques handle this:

  • Look at the parents' full command lines. For each suspicious exec, the helper walks the parent chain through libproc and sysctl(KERN_PROCARGS2) and skips the event if any ancestor is a MacSentry binary. Process names are not enough here; you need the command line to see /usr/local/libexec/macsentryd.
  • Mark expected events when the parent is already gone. To read the screen lock setting the daemon runs sudo -u <user> sysadminctl, and by the time the sudo event arrives, the intermediate processes have exited. So the daemon touches a marker file just before the call, and the helper ignores a root-initiated sudo within 15 seconds of it.

Walking from the recorded parent PID, not the process itself, also matters: short-lived processes have often exited before the event is handled.

Small operational details that prevent silent failures

  • Send eslogger's stderr to a file, not a pipe. If nobody reads a pipe while the child runs, it eventually fills and eslogger blocks forever, with no error anywhere. A file also keeps the last error for diagnostics, such as "not permitted" when Full Disk Access is missing.
  • Report liveness even when the machine is idle. The helper touches a heartbeat file on events and on a 60-second timer while eslogger is running, and the daemon alerts if that file goes stale. Without the timer, a quiet machine would look like a dead helper.
  • Hand results to the daemon through a spool with an atomic rename. The helper writes name.tmp, then renames it to name.msg, so the daemon never reads a half-written message. The helper never holds the Telegram token, and it masks bot tokens and KEY=value style secrets before writing anything.

Summary

Problem What MacSentry does
Endpoint Security needs an Apple-approved entitlement Run /usr/bin/eslogger as a child of a root helper with Full Disk Access
Output format is not a stable API Save a raw sample per event type, read fields tolerantly
exec lines are large and frequent getline plus a one-pass Aho–Corasick prefilter on raw bytes (8.7 s → 0.77 s per 100k events)
Rules matching text inside data Rules scoped to the executable; data carriers never evaluated
Alerts on its own commands Check parents' command lines; marker file for expected sudo
Silent hangs and silent death stderr to a file, liveness file on a timer, atomic spool

eslogger is not a replacement for a signed system extension: it cannot block anything and its format can change under you. For a personal tool that only needs to see what happens on one Mac, it gets surprisingly close.

If you've built a monitor on eslogger or a native Endpoint Security client, I'd love to compare notes. My email is on the home page.