Echo
The Echo plugin continuously captures system state snapshots and records changes as timestamped events in a local SQLite database on each endpoint. It enables retrospective investigation of “what happened on this machine” without requiring pre-configured logging or SIEM integration.
Engineers maintaining or extending Echo should also read
docs/echo-implementer.md— it covers the on-disk format, persistence semantics across upgrade/uninstall/reinstall, the post-restart double-capture caveat, and device-impact expectations.
What Echo captures
Echo monitors these categories of system activity. Every capture source is on by default on every OS — each can be disabled per host with <source>_enabled=false (see Configuration). power and removable media (Wave 6) ship collecting in this release: power (Windows/Linux live subscription; macOS retrospective replay bounded by power_lookback_seconds) — an empty $Power_Live means no transitions occurred, not that collection has not started; removable media (Windows retrospective event-log replay bounded by removable_lookback_seconds; Linux/macOS live-only) — an empty $Removable_Live means no removable-media activity occurred, not that collection has not started. usage (Wave 7) also ships enabled by default alongside them: it derives per-executable run history directly from the always-on process source (no separate collector of its own), starting from the first tick after enablement or upgrade — see the usage_enabled row below for the access-limitations note. software, ARP, DNS, and mapped drives are on by default — software/DNS are Windows-only today (software is machine scope only — ADR-0015 covers ARP/DNS), while ARP also runs on Linux (/proc/net/arp) and macOS (constrained — a sysctl route-table dump, entry_type always reported unknown), and mapdrive (network-share mappings in both directions, §3.8) also runs on Linux and macOS (constrained on macOS — outbound-live only via getfsstat; the username column is always blank, but a credentialed SMB mount source is stored verbatim in remote_path; no inbound, no history). registry (Windows only) records changes to a curated set of registry keys — Run/RunOnce, Services, Image File Execution Options, AppInit_DLLs, Winlogon, LSA, Policies and Defender under HKLM, and the Run, RunOnce, Policies and Winlogon keys of every loaded user hive — with each value’s old and new data; the process that made a change is named by correlation where the agent can (a lead to verify, not proof, and only with an elevated agent — see Registry table under Key columns); its values are stored unmasked (see Registry table under Key columns). Further sources — per-app performance, module loads, per-connection network quality (netqual), and connectivity-transition history (netconn — network/Wi-Fi connect/disconnect + internet-capability changes, including a retrospective backfill of OS-retained history from before Echo was enabled; ADR-0020) — are covered under Configuration and the OS compatibility matrix below.
| Category | Collection interval | Events detected |
|---|---|---|
| Processes | event-driven on Windows (ETW) and macOS (Endpoint Security), gap-free; 60 s poll on Linux | Process started, process stopped |
| Network connections | 60 seconds (fast) | Connection opened, connection closed |
| Services | 300 seconds (slow) | Service started, stopped, state changed |
| User sessions | 300 seconds (slow) | User login, user logout |
| Software | 3600 seconds (software) | Application installed, removed, upgraded |
| Performance | 30 seconds (perf) | Device CPU/memory/disk/network sample (a scalar reading, not a diff) |
| ARP (Windows/Linux/macOS) | 60 seconds (fast) | ARP / neighbour binding appeared, removed |
| DNS cache (Windows) | 60 seconds (fast) | DNS resolver-cache entry appeared, removed |
| Registry (Windows) | change-driven key watch plus a full re-read every 12 slow ticks (hourly at the default 300 s); a change is read at the slow tick that sees it (every slow_interval, 300 s by default) and written in that same tick, or in the next one when the write happened within a few seconds of the tick or the hourly full re-read found it | Registry value added, changed, removed; key removed |
Every source except Performance and the cursor-model sources (power, removable, which track a durable log position instead) takes a snapshot of the current state each cycle, compares it to the previous snapshot, and records only the differences as events. This keeps the database compact while providing full visibility into system changes.
The Software source diffs the installed-software inventory to record install / uninstall / upgrade events over time — the historical “what was installed or removed on this box, and when” that the point-in-time installed_apps inventory cannot answer. On Windows it captures machine-wide installs only (the HKLM Uninstall keys, 64-bit and 32-bit) — machine scope only, no user identity / no PII. It runs on its own slower trigger (hourly by default — installs are infrequent) and is on by default: disable it per host with software_enabled=false (see software_enabled below). The inventory it gathers is asset-management and vulnerability-relevance data with no user identity, like Services and User sessions. It records names, versions, and publisher only — no command lines, no usage data.
The first run on a host seeds the baseline silently (it records no events) so an installed event always means “installed now”, not “was already present when the agent started watching”. Linux (dpkg/rpm) and macOS (pkgutil) collectors are planned; the $Software_* tables are queryable but stay empty on those platforms until then.
The Performance source is different: it is a fixed-cadence scalar sample, not an event diff. Each 30-second tick records one row of derived device metrics — CPU busy %, memory used % and commit-charge %, per-IO disk service time (µs) and read/write throughput, and non-loopback network rx/tx throughput. It is collected from raw kernel counters (no PDH, no WMI, no shell-out) and, like all Echo data, stays on the device — only aggregates leave the edge. On Windows and Linux it is fully supported (Windows: kernel counters; Linux: procfs); the macOS collector is planned. The first sample after the agent starts is a baseline (it establishes the counter reference and records no row); every subsequent tick records one sample.
Querying Echo data
From the Yuzu dashboard
Use the Echo Query instruction to search events by time range and type.
From the REST API
POST /api/v1/instructions/execute
{
"plugin": "echo",
"action": "query",
"parameters": {
"from": "1711000000",
"to": "1711100000",
"type": "process",
"limit": "500"
}
}
Query parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
from | No | 0 | Start of time range (Unix epoch seconds) |
to | No | now | End of time range (Unix epoch seconds) |
type | No | all | Filter: process, network, service, or user |
limit | No | 1000 | Maximum results (max 10000) |
Output format
Each event is output as a pipe-delimited line:
timestamp|event_type|event_action|snapshot_id|detail_json
Example:
1711050123|process|started|1711050123001|{"pid":1234,"ppid":1,"name":"nginx","cmdline":"nginx -g daemon off;","user":"www-data"}
1711050123|network|connected|1711050123001|{"proto":"tcp","local_addr":"0.0.0.0","local_port":80,"remote_addr":"10.0.0.5","remote_port":54321,"state":"ESTABLISHED","pid":1234}
1711050423|service|state_changed|1711050423002|{"name":"sshd","display_name":"OpenSSH","status":"stopped","prev_status":"running","startup_type":"automatic","prev_startup_type":"automatic"}
1711050423|user|login|1711050423002|{"user":"admin","domain":"CORP","logon_type":"remote","session_id":"pts/0"}
The
cmdlinefield above is shown populated for a Linux example. On Windows (ETW) and macOS, the process feeder is names-only, socmdlineis empty — see the OS compatibility matrix below. On macOS this holds on both paths: when the Endpoint Security stream is unavailable the agent falls back to theKERN_PROC_ALLsysctl poll, which also blankscmdline(only Linux populates it).
JSON export
Use the export action for JSON output suitable for integration with Splunk, ClickHouse, or ELK:
POST /api/v1/instructions/execute
{
"plugin": "echo",
"action": "export",
"parameters": { "type": "process", "limit": "100" }
}
Configuration
Use the configure action to adjust Echo behavior.
| Setting | Valid range | Default | Description |
|---|---|---|---|
retention_days | 1-365 | 7 | Days to keep events before automatic purge |
fast_interval | 10-3600 | 60 | Seconds between process/network collections |
slow_interval | 30-7200 | 300 | Seconds between service/user collections |
software_interval | 0 or 300-86400 | 3600 | Seconds between software install/uninstall scans. 0 disables the scan trigger entirely (no collection until re-set or agent restart). |
redaction_patterns | JSON array | See below | Patterns for command-line redaction (case-insensitive). Merged with the built-in defaults — added to, never replacing them; the defaults cannot be disabled. |
process_enabled | true / false | true | Toggle the process collector on this host |
tcp_enabled | true / false | true | Toggle the network collector on this host |
service_enabled | true / false | true | Toggle the service collector on this host |
user_enabled | true / false | true | Toggle the user-session collector on this host |
perf_enabled | true / false | true | Toggle the device performance sampler on this host |
software_enabled | true / false | true | Toggle the software install/uninstall source (software → $Software_*) on this host. On by default; set to false to opt out. On Windows it covers machine-wide installs (HKLM) only: machine scope only, no user identity / no PII — asset-management and vulnerability-relevance data like Services and User sessions. Disabling leaves existing rows queryable. |
procperf_enabled | true / false | true | Toggle the per-application top-N sampler on this host. On by default; set to false to opt out. Independent of perf_enabled (device-level perf carries no per-app identity). |
netqual_enabled | true / false | true | Toggle the per-connection TCP-quality sampler (netqual source → $NetQual_Live, plus the per-boot retrospective baseline $NetQual_Boot) on this host. On by default. Only a coarse destination class (loopback/private/public) is stored; raw remote addresses are dropped at the edge and never persisted, and the owning process is recorded as its image name only. Linux, and Windows via TCP ESTATS (ADR-0020) — Windows needs an elevated agent (non-elevated records nothing; echo.status reports `netqual_capture_method |
netconn_enabled | true / false | true | Toggle the connectivity-transition source (netconn → $NetConn_Live, ADR-0020). On by default. Windows reads the OS-retained NetworkProfile / NCSI / WLAN-AutoConfig event logs, so the FIRST read backfills history from before Echo was enabled (or installed) — network connect/disconnect, internet-capability changes, Wi-Fi connect/fail/disconnect with reason codes. Only closed enum tokens and numeric reason codes are stored: no SSID, BSSID, profile name, interface GUID, or MAC is ever extracted. Linux/macOS are planned (schema registered, queryable-empty). Set to false to opt out. |
netconn_lookback_seconds | integer seconds | 604800 (7 days) | How far before enablement the netconn backfill reads OS-retained connectivity history (ADR-0020 privacy note). Because the timing of network/Wi-Fi connect/disconnect events is a presence/working-hours proxy — behavioral data even with SSIDs stripped — enabling netconn retroactively ingests a window that predates any monitoring disclosure. Set to 0 to disable the retrospective read entirely (the source then records only forward connectivity from the moment it is enabled), for jurisdictions or works-council agreements where pre-notice collection is not permitted. Clamped to [0, 90 days]. Applies to the first read after enablement (and after a late enable); once caught up the source reads only forward. |
module_enabled | true / false | true | Toggle the image-load / module-stream capture source (module source → $Module_*). On by default — module-load capture is high-volume (every DLL/dylib/.so load and driver/kext/kmod load per process, with a code-signing verdict). Loaded-image directories are captured (the search-order-hijack signal is the path) but the user-profile segment of a path is scrubbed at the edge (C:\Users\<redacted>\…); no command line is ever captured. Windows records image loads today (ETW, with the code-signing verdict resolved at drain); the macOS (Endpoint Security, M4/M5) and Linux (auditd, M6) collectors are planned, and there the $Module_* tables are queryable but return zero rows (see echo-module-loads.md). Set to false to opt out. |
arp_enabled | true / false | true | Toggle the ARP / neighbour-table capture source (arp source → $ARP_Live/$ARP_Hourly) on this host (ADR-0015). On by default. Captures IP↔MAC bindings per interface for Layer-2 adjacency / ARP-spoofing forensics. Windows (GetIpNetTable2, full interface/IP/MAC/entry_type) and Linux (/proc/net/arp, same detail) are fully supported; macOS is constrained (sysctl NET_RT_FLAGS route-table dump — entry_type always reported unknown, no interface field). Set to false to opt out; collected at fast_interval. |
power_enabled | true / false | true | Toggle the power capture source (power → $Power_Live): AC attach/detach and sleep/wake transitions. On by default, like every other capture source. The timing of sleep/wake and AC events is a presence/working-hours proxy: set power_lookback_seconds=0 for forward-only collection where a retrospective read is not lawful. |
power_lookback_seconds | 0 or up to 7776000 (90 days) | 604800 | How far back the first macOS read replays OS-retained power history (Windows/Linux ignore this key — they are live subscriptions with no history API). 0 = forward-only — the control to use where retrospective collection is not lawful; a value that cannot be read is treated as 0, never as the default. |
removable_enabled | true / false | true | Toggle the removable-media capture source (removable → $Removable_Live): USB/removable attach and detach with device identity, plus executed-from-removable evidence. On by default, like every other capture source. Records vendor/product/serial and executed-binary paths, which is identity/usage-class data. |
removable_lookback_seconds | 0 or up to 7776000 (90 days) | 604800 | How far back the first Windows read backfills OS-retained removable history (Linux/macOS ignore this key — they are live-only, no history API). 0 = forward-only — a value that cannot be read is treated as 0, never as the default. |
registry_enabled | true / false | true | Toggle the Windows registry change source (registry → $Registry_Live). On by default, like every other capture source; on Linux and macOS the source is listed as unsupported and records nothing. Values are stored unmasked: any value under a watched key, changed or not, is readable through echo.sql — the per-key baseline in echo_state stores every current value (for example a Winlogon autologon DefaultPassword), and $Registry_Live holds the old and new data of every change — so treat both accordingly or set false. Rows name the acting process by correlation where the agent can (a lead, not proof; it needs an elevated agent, otherwise rows say attr:off — see Registry table under Key columns). Setting false pauses capture; a later true re-baselines every watched key and records a capture_gap rather than inventing rows for what changed while paused. Disabling leaves existing rows queryable until echo.purge_source (on the paused source) erases them — the rows, the per-key baselines and the cursor row. Retention is a row count (500000). The default scope includes HKLM\SYSTEM\CurrentControlSet\Services, which contains bam\State (written on most process starts), so on a busy host the 500000-row cap can fill within days and the oldest rows age out; set false to opt out. |
usage_enabled | true / false | true | Toggle the derived app-usage fold (usage source → $Usage_Live/$Usage_Daily): pairs process_live started/stopped events into per-executable runs (run_usage_fold, no collector of its own — see echo-implementer.md §8). On by default, like every other capture source. What a device runs and when is a presence/working-hours proxy even though usage carries no user column of its own (open runs record a user field, folded into a distinct_users count on write). Coverage begins once the source reaches its Active lifecycle state after enablement or upgrade — strictly forward-only, never a retrospective fold over history that accrued before that point. Set to false to opt out; disabling freezes retention rather than accelerating it (like every other Echo source, #539), and existing usage_daily/usage_daily_user/usage_live rows stay queryable until they age out on their own schedules (31-day usage_daily/usage_daily_user; the fold’s own 20000-run open-run cap on usage_live). Access posture (enforced): usage_live/usage_daily/usage_daily_user are queryable ONLY through the Forensics-gated app_usage reads, never through the generic echo.sql action — echo.sql (Infrastructure:Read) denies all three tables (direct name, $Usage_* placeholder, alias, or JOIN/subquery), matching the posture this section already documents for tar_events. usage_enabled and the echo.status usage counters are unaffected by this — they report on the fold regardless of how (or whether) the data is subsequently read (issue #4260). |
dns_enabled | true / false | true | Toggle the DNS resolver-cache capture source (dns source → $DNS_Live/$DNS_Hourly) on this host (ADR-0015). On by default — the DNS cache reveals which domains a host resolved. Device-level state only — no per-process attribution (the cache carries no PID). Windows only today (DnsGetCacheDataTable, cache-only — never issues a wire query); Linux/macOS planned. Set to false to opt out; collected at fast_interval. |
mapdrive_enabled | true / false | true | Toggle the mapped-drive capture source (mapdrive source → $MapDrive_Live/$MapDrive_Hourly) on this host (capability-map §3.8). On by default — rows expose usernames and remote share paths. Records network-share mappings in both directions (direction column): outbound (drives this host maps to remote shares) and inbound (remote hosts mapping this host’s shares — the lateral-movement signal). Windows + Linux fully; macOS is constrained — outbound live only via getfsstat (the username column is always blank, but a credentialed SMB mount source is stored verbatim in remote_path; no inbound, no history: honestly out of reach for an unprivileged agent). Inbound needs local-admin/Server-Operator on Windows and Samba on Linux, degrading to empty otherwise. Collected at slow_interval. A one-time historical backfill seeds previously mapped drives (from the registry / fstab / event logs) as origin='historical' rows — it runs at agent init, so it materializes on the first agent restart after the source is enabled (on an upgraded agent, the same first start that applies the v8 migration). Set to false to opt out. |
perf_interval_seconds | ≥ 1 | 30 | Seconds between performance samples (device and per-app, when each is enabled — they share the tick). 0 disables registration of the perf trigger entirely. Read at agent startup (trigger registration), not via echo.configure — change it through the agent’s KV config / install profile; a running agent picks it up on restart. (To stop perf collection at runtime without a restart, use perf_enabled=false.) |
network_capture_method | polling (always accepted) plus the methods accepted on the running host’s OS — e.g. iphlpapi on Windows, procfs on Linux, proc_pidfdinfo on macOS (run compatibility for the live list) | polling | Network capture mechanism. polling is the platform default — the only mechanism actually wired today. Other values are accepted for pre-staging when the corresponding kernel-event collector lands; the agent emits a warn line and continues polling. Methods from a different OS are rejected. The status action reports network_capture_method_effective alongside the configured value so the configured-vs-active discrepancy is always explicit. |
process_stabilization_exclusions | JSON array (≤256 × ≤256 chars) | [] | Process-name substrings (case-insensitive; leading/trailing * stripped; ? and [abc] are literals; effective substring must be ≥3 chars) to drop before diffing. Useful for noisy short-lived helpers (CI runners, IDE indexers) that dwarf real activity. Trade-off: forensic completeness is reduced — anything matching these patterns is invisible to Echo. |
Validation rules:
- When both
fast_intervalandslow_intervalare provided,fast_intervalmust be less thanslow_interval. redaction_patternsandprocess_stabilization_exclusionsmust each be a JSON array of non-empty strings, with at most 256 elements of at most 256 characters each (both feed an O(patterns × command-line-length) scan per process per cycle).process_stabilization_exclusionsmatching is case-insensitive substring, not real glob. Leading and trailing*are stripped;?and[abc]are treated as literals. The effective substring (after stripping*) must be at least 3 characters — a shorter one (e.g."a", or"*a*", which strips to"a") would match almost every process and is rejected. Use a longer substring ("-helper","chrome-helper"). Matching is on the process name only, so a binary renamed to contain an approved substring evades capture — exclusions are a noise-reduction convenience, not a threat-hunting control.network_capture_methodis validated against the running host’s OS accept-list, not the union across all platforms — e.g. a Linux agent rejects the Windows-onlyiphlpapi.pollingis always accepted (the platform default). Run thecompatibilityaction to see the live accepted list for a given agent.- Disabling a collector (
<source>_enabled=false) atomically stops new captures — the disable runs under the collection lock, so an in-flight cycle either completes before the stop takes effect or observes the disabled flag and skips (no extra snapshot slips through). Existing rows remain queryable. On re-enable the source starts from a clean baseline: the snapshot-diff state is cleared on disable, so the first collection cycle after re-enable emits astartedevent for every entity currently running/open (an expected one-time rebaseline, not a real-time burst) and never ghoststoppedevents for entities that exited during the pause. If the agent database is momentarily busy and the baseline cannot be cleared, the disable is refused — the source stays enabled andconfigurereturns an error — so a disabled source can never be left with a stale baseline; retry the disable.- The interval samplers (
perfandprocperf) report the delta between consecutive readings rather than started/stopped events, and keep that previous reading in memory. Disabling them resets that in-memory baseline under the same collection lock, and the sampler re-checks the disabled flag after taking the lock — so disabling them is equally atomic, and the first sample after a re-enable establishes a fresh baseline rather than reporting one row whose averaged rate covers the entire paused window. This matters most forprocperf(per-application CPU/memory): no usage row ever spans the period it was disabled.
- The interval samplers (
Example:
POST /api/v1/instructions/execute
{
"plugin": "echo",
"action": "configure",
"parameters": {
"retention_days": "14",
"fast_interval": "30",
"user_enabled": "false",
"process_stabilization_exclusions": "[\"*-helper*\",\"*ide-language-server*\"]"
}
}
OS compatibility matrix
Echo runs on Windows, Linux, and macOS, but each capture source has platform-specific constraints. Run the compatibility action to print the live matrix from the agent itself; the table below is a snapshot for documentation purposes.
macOS Endpoint Security development bundle
An opt-in development package can wrap the existing Echo process collector in a
signed YuzuAgent.app. It is not a release/notarization lane. Packaging requires
an explicit current macOS App ID profile with the ES entitlement set to Boolean
true, a matching signer and provisioned Mac, plus an SDK that ships the Endpoint Security
header and library stub (both the Command Line Tools and full Xcode SDKs do). The root
LaunchDaemon always disables bare-executable OTA updates. External plugins remain
subject to Apple library validation and Yuzu final-byte CMS policy. These artifact
checks do not establish live ES: verify echo.status reports
process_capture_method|endpoint_security, then observe a unique exec/exit pair
and check both drop counters. Polling is an honest fallback, never ES proof.
| Source | Windows | Linux | macOS |
|---|---|---|---|
| process | supported (etw) — Microsoft-Windows-Kernel-Process real-time session: gap-free start/stop (catches short-lived processes the poll misses), exact timestamps + exit code. Names only — no command line (the start event carries none; aligns with the privacy posture). Owning user resolved from the SID at start (empty for processes that exit faster than ETW’s ~1s buffer flush — the same limit the poll has). Falls back to the toolhelp32 poll if the ETW session cannot start. Boot gap: processes that start and exit before the agent’s live session opens are backfilled from a boot AutoLogger (a circular, FlushTimer-enabled Kernel-Process .etl configured by the InnoSetup installer and install-agent-user.ps1, started by the kernel early each boot); the agent reads it directly at startup for events before the live session began (no session stop / no elevation — read access only), de-duplicated per boot. Takes effect from the next boot after install. Boot-window events are names-only with no user (the start event carries no user SID — precise attribution would need the Security-Auditing 4688 provider); if the AutoLogger isn’t configured, that narrow window is simply not captured. | supported (procfs) — /proc/<pid>/status and /proc/<pid>/cmdline. | constrained (endpoint_security) — Endpoint Security NOTIFY_EXEC/NOTIFY_EXIT stream: gap-free start/stop, full image path, accurate ppid, owning user from the audit token. Names only — no command line (parity with the Windows ETW posture). Requires a build with the ES SDK present (shipped in both the Command Line Tools and full Xcode SDKs — “needs full Xcode” is a myth), the com.apple.developer.endpoint-security.client entitlement, and root. Falls back to the KERN_PROC_ALL sysctl poll when the stream is unavailable (ES SDK not detected at build time, missing entitlement, or non-root) — the poll is also names-only (it blanks the proc_pidpath image it would otherwise place in cmdline), so macOS process rows carry no command line on either path. Boot gap (as on Windows): processes alive before the agent’s session opens get no started row — macOS has no AutoLogger-equivalent backfill. Not active in released builds, so released macOS agents poll. The entitlement can only be granted to a signed app bundle through its provisioning profile. The release package ships a bare yuzu-agent executable, which cannot carry it. Running ES in a release therefore means shipping the app-bundle lane, which updates by package (MDM) rather than over the air and needs a Full Disk Access (PPPC) profile, which ES clients require on any Mac, together with the organisation’s Apple entitlement (#1455). The opt-in development bundle above can run the ES path, but only on a Mac provisioned under a developer’s own ES entitlement; it is not a release lane. Check process_capture_method in echo.status to see the live path. |
| tcp | supported (iphlpapi) — GetExtendedTcpTable polled at fast_interval. ETW (Microsoft-Windows-Kernel-Network) is planned for sub-second fidelity; not yet wired. | supported (procfs) — /proc/net/{tcp,tcp6,udp,udp6}. Connection lifetime below fast_interval may be missed. | constrained (proc_pidfdinfo + nstat) — proc_listallpids + proc_pidfdinfo(PROC_PIDFDSOCKETINFO) via libproc remains the fallback/seed poll (same TOCTOU caveat: short-lived sockets that close before the per-fd query may produce empty rows). Connection lifecycle is event-driven via the same nstat kernel-control client that serves netqual: SRC_ADDED/SRC_REMOVED messages fire on open/close. Not Endpoint Security — ES has no TCP/socket event notifications, only process exec/exit. |
| service | supported (scm) — EnumServicesStatusEx / QueryServiceConfig; full status + startup_type. | constrained (systemctl) — systemctl list-units; startup_type reported as unknown. Hosts without systemd (Alpine sysvinit, OpenRC) are unsupported. | constrained (launchctl) — launchctl list; no startup_type, status binary running/stopped only. |
| user | supported (wts) — WTSEnumerateSessionsW + WTSQuerySessionInformationW; interactive, RDP, console. Server Core 2008 R2 minimal installs lack Terminal Services. | constrained (utmp) — getutent. Containers without /var/run/utmp produce no events. logon_type inferred from tty (pts/* → remote). | constrained (utmpx) — getutxent. GUI logins are not always reflected. |
| perf | supported (ntcounters) — GetSystemTimes, GlobalMemoryStatusEx/GetPerformanceInfo, IOCTL_DISK_PERFORMANCE, GetIfTable2. No PDH, no WMI, no shell-out. Some virtual disks do not answer IOCTL_DISK_PERFORMANCE — disk columns read 0 there. | supported (procfs) — /proc/stat, /proc/meminfo (MemAvailable), /proc/diskstats (whole disks only, fixed 512-byte ABI sectors), /proc/net/dev (loopback excluded). No shell-out. commit_pct reads 0 under vm.overcommit_memory=1 (CommitLimit is advisory there); a host with no recognised whole-disk device (some containers) reads 0 in the disk columns — same per-domain degrade as a Windows virtual disk. Containerized agents report host-wide readings (/proc/stat, /proc/meminfo and the CPU count are not cgroup-scoped), so a cgroup-throttled workload can look idle in device perf — run the agent on the host for container fleets. | planned (host_statistics) — host_processor_info / host_statistics64 + IOKit. Records nothing until wired. |
| procperf | supported (ntsysinfo), on by default — one NtQuerySystemInformation(SystemProcessInformation) snapshot per tick: image name, CPU times, working set for every process. No PDH, no WMI, no per-process handles. Records image names only — never command lines; redaction patterns apply to the name (as bare case-insensitive substrings — a pattern meant for a command-line argument can match an image name, so over-matching drops a process from the warehouse entirely). version is the image’s on-disk file version, or "" (the unknown-version bucket) when it has none or the image is not on a local fixed volume: a network-path image is never opened. | supported (procfs), on by default — one /proc/<pid>/stat read per process per tick: comm, utime+stime, rss, starttime; no ptrace, no per-process handles. Kernel threads (e.g. kworker/*) are included, matching Windows recording the System process — they are kernel scheduling infrastructure, not user-app usage, and rss 0 keeps them out of the working-set top-N; one appears in the CPU top-N only when genuinely hot. Names are the kernel’s 15-character comm (at most 15 characters before escaping — \ and newline are stored kernel-escaped) — the same value the process source records, so procperf rows join $Process_Live by name. Redaction here is best-effort against the 15-byte comm, not a guarantee: a pattern only redacts reliably when its sensitive substring is a ≤15-byte, prefix-aligned slice of the comm — the kernel truncates the name to 15 bytes, so a token that falls past byte 15 (averylongprefix_leak → comm averylongprefix) is physically gone and cannot be matched, and a Windows-style core like outlook.exe does not match the extension-less comm outlook. As a partial mitigation a pattern core longer than 15 bytes is also matched by its 15-byte prefix, which only ever adds redaction (and can over-suppress an unrelated same-prefix app — e.g. SensitiveApp also hides SensitiveAppliance). Write Linux patterns as ≤15-byte prefix-aligned comm fragments, and treat comm redaction as reducing — not eliminating — per-app exposure; the durable fix (matching a fuller name source than the 15-byte comm) is a tracked follow-up. version is always "" (on-disk version capture is a follow-up). | planned (libproc) — proc_pid_rusage/proc_taskinfo. Records nothing until wired. |
| module | supported (etw), on by default — Microsoft-Windows-Kernel-Process image-load events with the code-signing verdict resolved at drain (WinVerifyTrust). An image on a network path (UNC, \Device\Mup or another redirector) is never opened: the row is kept with an unknown verdict. An NT volume path on any local volume, removable included, is opened: once, through a handle that follows no reparse point (NtCreateFile with OBJ_DONT_REPARSE). A symbolic link or junction anywhere in the path, or any open failure, leaves the verdict unknown and the row kept; an unknown row is not counted risky, so it is deduplicated and capped like a normal load. Compressed (CompactOS), deduplicated and cloud-placeholder images verify normally. | planned (auditd) — kernel-module loads via init_module/finit_module (M6). Records nothing until wired. | planned (endpoint_security) — NOTIFY_KEXTLOAD/KEXTUNLOAD + dylibs (M4/M5). Records nothing until wired. |
| power | constrained (powerbroadcast) — suspend/resume + AC transitions via PowerRegisterSuspendResumeNotification/PowerSettingRegisterNotification; a live subscription, so no history before the agent starts (or before it was last unarmed — a disabled window or restart is reported as an explicit capture_gap, never silently backfilled). | constrained (logind) — systemd-logind PrepareForSleep over sd-bus plus power-supply sysfs; live-only, gated on the optional libsystemd dependency (unarmed without it — reported, not silent). | supported (pmset_log) — pmset -g log retrospective replay, the only leg with real history, bounded by power_lookback_seconds (default 7 days; 0 = forward-only). On by default. |
| removable | supported (wevtapi) — OS event log, so this leg has real retained history bounded by removable_lookback_seconds. | constrained (udev_netlink) — udev netlink subscription; live-only, no history. | constrained (diskarbitration) — DiskArbitration attach/detach callbacks plus a getfsstat baseline; live-only, no history API. On by default. |
| registry | supported (regnotify+etw), on by default — RegNotifyChangeKeyValue subtree watch over a curated key list plus a baseline diff (what changed, old and new data). A write-only Kernel-Registry ETW session adds the writing process by correlation (a lead, not proof; elevated agent only, else rows say attr:off). Values are unmasked. | unsupported — the Windows registry does not exist on this platform; the source is listed but records nothing. | unsupported — the Windows registry does not exist on this platform; the source is listed but records nothing. |
| software | supported (registry), on by default — diffs the registry Uninstall keys on the echo.software tick: HKLM 64-bit + WOW6432Node 32-bit (machine scope only, no user identity / no PII). SystemComponent entries (canonically a REG_DWORD set to a non-zero value) are excluded — system components and OS patches are not reported as installed software; names, versions, and publisher only. First run seeds the baseline silently. | planned (dpkg_rpm) — dpkg/rpm/pacman diff. Records nothing until wired. | planned (pkgutil) — system_profiler + pkgutil diff. Records nothing until wired. |
| arp | supported (iphlpapi), on by default — GetIpNetTable2(AF_UNSPEC): ARP + IPv6 neighbour cache; full interface/IP/MAC/entry_type (ADR-0015). | supported (procfs), on by default — /proc/net/arp: IP/MAC/interface, entry_type (static/dynamic/incomplete/other) mapped from the Flags column. Capped at 2048 live entries. | constrained (route_sysctl), on by default — reuses the shared NET_RT_FLAGS/RTF_LLINFO routing-socket dump; MAC available, entry_type always unknown (this source distinguishes neither static/permanent nor dynamic/stale/probe entries), no interface field. Capped at 2048 live entries. |
| dns | supported (dnsapi), on by default — DnsGetCacheDataTable + cache-only DnsQuery_W: name/record_type/data/TTL (ADR-0015). Device-level resolver-cache state; no per-process attribution. | planned (systemd-resolved) — resolve1 D-Bus / /etc/hosts fallback (constrained). Records nothing until wired. | planned (dscacheutil) — subprocess; TTL unavailable (ttl_remaining_s = -1, constrained). Records nothing until wired. |
| netqual | constrained (estats) — TCP ESTATS (GetPerTcpConnectionEStats) per ESTABLISHED connection (ADR-0020). Requires an elevated agent: enabling per-connection stats is admin-only, so a non-elevated agent records nothing and echo.status reports netqual_capture_method=none. RTT is ms-resolution (sub-ms LAN RTTs read 0); retrans/segs_out count since stats-enable, not connection start; lost/ca_state are delta-derived approximations of the Linux gauges. The per-boot $NetQual_Boot baseline (since-boot OS counters) is captured non-elevated. | supported (inetdiag) — netlink SOCK_DIAG/INET_DIAG TCP_INFO, joined to the owning process by 4-tuple. | constrained (nstat) — per-socket tcp_connection_info read from the private com.apple.network.statistics kernel control (no framework, no entitlement). Requires root for system-wide flow visibility: an unprivileged agent sees only its own process’s flows, so it records nothing and echo.status reports netqual_capture_method=none (mirrors the Windows admin-only posture). RTT is already microsecond-resolution (no *1000 conversion needed, unlike Windows ms). lost is a per-tick delta, like Windows — nstat exposes cumulative rxretransmit/txretransmit, not an instantaneous loss gauge. retrans/segs_out are cumulative-since-flow-open context. The struct layout is private/version-unstable; a runtime self-check falls back to capture_method=none on mismatch rather than emit wrong values (docs/darwin-compat.md). Behavior pending validation on real hardware. |
| netconn | supported (wevtapi) — EvtQuery over the OS-retained NetworkProfile / NCSI / WLAN-AutoConfig operational channels (ADR-0020): network connect/disconnect, internet-capability changes, Wi-Fi connect/fail/disconnect + reason codes. The first read backfills history from before Echo (or the agent) existed — reach is bounded by netconn_lookback_seconds (default 7 days; 0 disables retrospective reads). Only closed enum tokens + numeric reason codes are stored — no SSID, BSSID, profile name, interface GUID, or MAC. A missing channel (Server SKU without WLAN) or an ACL-denied channel is skipped (warned once). | planned (journald) — NetworkManager/systemd-networkd transitions. Records nothing until wired. | planned (oslog) — configd/Wi-Fi subsystem transitions. Records nothing until wired. |
| mapdrive | supported (wnet/netapi32/registry), on by default — both directions (§3.8). Outbound live WNetEnumResourceW; outbound history registry Network/MRU/MountPoints2 across offline profiles — reads each logged-out profile’s registry hive, mounting it offline via the same ladder registry.get_user_value uses (#2771), capped at 512 profiles like every other consumer of that ladder (a host with more loses outbound history for the profiles past the cap, logged once, not per profile). This is part of the one-time historical backfill (§3.8, mapdrive_enabled row below) — it runs once, at init, gated by a durable mapdrive_backfill_done key, not on a recurring cadence; enabling mapdrive means one pass over every logged-out profile’s NTUSER.DAT on the first agent (re)start after enabling, not an ongoing background job. Inbound live NetSessionEnum (needs local-admin/Server-Operator, degrades to empty); inbound history Security log 4624 network logons. | constrained (procfs) — outbound live /proc/mounts (no username) + history /etc/fstab (ts=0); inbound smbstatus + /var/log/samba connect logs, both requiring Samba (empty otherwise). | constrained (getfsstat) — outbound live only: getfsstat(2) mount table filtered to {nfs, smbfs, cifs, afpfs, webdav}; the username column is always blank (getfsstat exposes no separate credential field), but a credentialed SMB mount source (e.g. //alice@host/share) is stored verbatim in remote_path, so the account name can still be recovered from there even though the username column is empty — unlike Linux, whose /proc/mounts device string never carries a username. No inbound (no smbutil integration) and no history (getfsstat exposes only the current mount table). |
| procperf | supported (ntsysinfo), opt-in (off by default) — one NtQuerySystemInformation(SystemProcessInformation) snapshot per tick: image name, CPU times, working set for every process. No PDH, no WMI, no per-process handles. Records image names only — never command lines; redaction patterns apply to the name (as bare case-insensitive substrings — a pattern meant for a command-line argument can match an image name, so over-matching drops a process from the warehouse entirely). | supported (procfs), opt-in (off by default) — one /proc/<pid>/stat read per process per tick: comm, utime+stime, rss, starttime; no ptrace, no per-process handles. Kernel threads (e.g. kworker/*) are included, matching Windows recording the System process — they are kernel scheduling infrastructure, not user-app usage, and rss 0 keeps them out of the working-set top-N; one appears in the CPU top-N only when genuinely hot. Names are the kernel’s 15-character comm (at most 15 characters before escaping — \ and newline are stored kernel-escaped) — the same value the process source records, so procperf rows join $Process_Live by name. Redaction here is best-effort against the 15-byte comm, not a guarantee: a pattern only redacts reliably when its sensitive substring is a ≤15-byte, prefix-aligned slice of the comm — the kernel truncates the name to 15 bytes, so a token that falls past byte 15 (averylongprefix_leak → comm averylongprefix) is physically gone and cannot be matched, and a Windows-style core like outlook.exe does not match the extension-less comm outlook. As a partial mitigation a pattern core longer than 15 bytes is also matched by its 15-byte prefix, which only ever adds redaction (and can over-suppress an unrelated same-prefix app — e.g. SensitiveApp also hides SensitiveAppliance). Write Linux patterns as ≤15-byte prefix-aligned comm fragments, and treat comm redaction as reducing — not eliminating — per-app exposure; the durable fix (matching a fuller name source than the 15-byte comm) is a tracked follow-up. version is always "" (on-disk version capture is a follow-up). | planned (libproc) — proc_pid_rusage/proc_taskinfo. Records nothing until wired. |
| module | planned (etw), opt-in (off by default) — Microsoft-Windows-Kernel-Process image-load events with the code-signing verdict resolved at drain. Schema registered + queryable now (M1); records nothing until the collector ships (M2). | planned (auditd) — kernel-module loads via init_module/finit_module (M6). Records nothing until wired. | planned (endpoint_security) — NOTIFY_KEXTLOAD/KEXTUNLOAD + dylibs (M4/M5). Records nothing until wired. |
| power | supported (powerbroadcast) — suspend/resume + AC transitions via PowerRegisterSuspendResumeNotification/PowerSettingRegisterNotification; a live subscription, so no history before the agent starts (or before it was last unarmed — a disabled window or restart is reported as an explicit capture_gap, never silently backfilled). | supported (logind) — systemd-logind PrepareForSleep over sd-bus plus power-supply sysfs; live-only, gated on the optional libsystemd dependency (unarmed without it — reported, not silent). | supported (pmset_log) — pmset -g log retrospective replay, the only leg with real history, bounded by power_lookback_seconds (default 7 days; 0 = forward-only). On by default. |
| removable | supported (wevtapi) — OS event log, so this leg has real retained history bounded by removable_lookback_seconds. | supported (udev_netlink) — udev netlink subscription; live-only, no history. | supported (diskarbitration) — DiskArbitration attach/detach callbacks plus a getfsstat baseline; live-only, no history API. On by default. |
| software | supported (registry), opt-in (off by default) — diffs the registry Uninstall keys on the echo.software tick: HKLM 64-bit + WOW6432Node 32-bit (machine scope only, no user identity / no PII). SystemComponent entries (canonically a REG_DWORD set to a non-zero value) are excluded — system components and OS patches are not reported as installed software; names, versions, and publisher only. First run seeds the baseline silently. | planned (dpkg_rpm) — dpkg/rpm/pacman diff. Records nothing until wired. | planned (pkgutil) — system_profiler + pkgutil diff. Records nothing until wired. |
| arp | supported (iphlpapi), opt-in (off by default) — GetIpNetTable2(AF_UNSPEC): ARP + IPv6 neighbour cache; full interface/IP/MAC/entry_type (ADR-0015). | supported (procfs), opt-in (off by default) — /proc/net/arp: IP/MAC/interface, entry_type (static/dynamic/incomplete/other) mapped from the Flags column. Capped at 2048 live entries. | constrained (route_sysctl), opt-in (off by default) — reuses the shared NET_RT_FLAGS/RTF_LLINFO routing-socket dump; MAC available, entry_type always unknown (this source distinguishes neither static/permanent nor dynamic/stale/probe entries), no interface field. Capped at 2048 live entries. |
| dns | supported (dnsapi), opt-in (off by default) — DnsGetCacheDataTable + cache-only DnsQuery_W: name/record_type/data/TTL (ADR-0015). Device-level resolver-cache state; no per-process attribution. | planned (systemd-resolved) — resolve1 D-Bus / /etc/hosts fallback (constrained). Records nothing until wired. | planned (dscacheutil) — subprocess; TTL unavailable (ttl_remaining_s = -1, constrained). Records nothing until wired. |
| netqual (opt-in) | constrained (estats) — TCP ESTATS (GetPerTcpConnectionEStats) per ESTABLISHED connection (ADR-0020). Requires an elevated agent: enabling per-connection stats is admin-only, so a non-elevated agent records nothing and echo.status reports netqual_capture_method=none. RTT is ms-resolution (sub-ms LAN RTTs read 0); retrans/segs_out count since stats-enable, not connection start; lost/ca_state are delta-derived approximations of the Linux gauges. The per-boot $NetQual_Boot baseline (since-boot OS counters) is captured non-elevated. | supported (inetdiag) — netlink SOCK_DIAG/INET_DIAG TCP_INFO, joined to the owning process by 4-tuple. | constrained (nstat) — per-socket tcp_connection_info read from the private com.apple.network.statistics kernel control (no framework, no entitlement). Requires root for system-wide flow visibility: an unprivileged agent sees only its own process’s flows, so it records nothing and echo.status reports netqual_capture_method=none (mirrors the Windows admin-only posture). RTT is already microsecond-resolution (no *1000 conversion needed, unlike Windows ms). lost is a per-tick delta, like Windows — nstat exposes cumulative rxretransmit/txretransmit, not an instantaneous loss gauge. retrans/segs_out are cumulative-since-flow-open context. The struct layout is private/version-unstable; a runtime self-check falls back to capture_method=none on mismatch rather than emit wrong values (docs/darwin-compat.md). Behavior pending validation on real hardware. |
| netconn (opt-in) | supported (wevtapi) — EvtQuery over the OS-retained NetworkProfile / NCSI / WLAN-AutoConfig operational channels (ADR-0020): network connect/disconnect, internet-capability changes, Wi-Fi connect/fail/disconnect + reason codes. The first read backfills history from before Echo (or the agent) existed — reach is bounded by netconn_lookback_seconds (default 7 days; 0 disables retrospective reads). Only closed enum tokens + numeric reason codes are stored — no SSID, BSSID, profile name, interface GUID, or MAC. A missing channel (Server SKU without WLAN) or an ACL-denied channel is skipped (warned once). | planned (journald) — NetworkManager/systemd-networkd transitions. Records nothing until wired. | planned (oslog) — configd/Wi-Fi subsystem transitions. Records nothing until wired. |
| mapdrive | supported (wnet/netapi32/registry), opt-in (off by default) — both directions (§3.8). Outbound live WNetEnumResourceW; outbound history registry Network/MRU/MountPoints2 across offline profiles — reads each logged-out profile’s registry hive, mounting it offline via the same ladder registry.get_user_value uses (#2771), capped at 512 profiles like every other consumer of that ladder (a host with more loses outbound history for the profiles past the cap, logged once, not per profile). This is part of the historical backfill (§3.8, mapdrive_enabled row below) — its first pass runs once, at init, gated by a durable mapdrive_backfill_done key; enabling mapdrive means one pass over every logged-out profile’s NTUSER.DAT on the first agent (re)start after enabling. Each offline mount is behind the shared default hive-file guard (related issue #5291): a profile whose hive the first pass could not read (not_found included) is retried individually afterwards — bounded, 24 hours apart, up to 7 attempts in total — until it resolves or is abandoned, so this is not a pure one-shot, ongoing-background-job-free source once a host has any unreadable profile. Inbound live NetSessionEnum (needs local-admin/Server-Operator, degrades to empty); inbound history Security log 4624 network logons. | constrained (procfs) — outbound live /proc/mounts (no username) + history /etc/fstab (ts=0); inbound smbstatus + /var/log/samba connect logs, both requiring Samba (empty otherwise). | constrained (getfsstat) — outbound live only: getfsstat(2) mount table filtered to {nfs, smbfs, cifs, afpfs, webdav}; the username column is always blank (getfsstat exposes no separate credential field), but a credentialed SMB mount source (e.g. //alice@host/share) is stored verbatim in remote_path, so the account name can still be recovered from there even though the username column is empty — unlike Linux, whose /proc/mounts device string never carries a username. No inbound (no smbutil integration) and no history (getfsstat exposes only the current mount table). |
Status values:
- supported — fully wired and exercised in CI.
- constrained — works but with a documented limitation.
- planned — not yet implemented;
network_capture_methodaccepts the value so you can pre-stage configuration. - unsupported — platform cannot supply the data at all (e.g.,
serviceon a kernel without a service manager).
To get the matrix at runtime (returns one row|... line per source/OS pair):
POST /api/v1/instructions/execute
{
"plugin": "echo",
"action": "compatibility"
}
Security: command-line redaction
Echo automatically redacts sensitive command-line arguments before storing process events. Any command line matching a redaction pattern has its cmdline field replaced with [REDACTED by Echo]. Redaction applies to command lines only: the registry source’s values are not redacted (see Registry table under Key columns).
Windows + macOS (stream) scope. On Windows the process source is the ETW Kernel-Process feeder; on macOS with the Endpoint Security stream active it is likewise names-only — both capture image names only, never a command line. The
cmdlinecolumn is therefore empty for those process rows, and these redaction patterns have no effect on them (there is nothing to redact). They still apply to per-app perf rows (procperf, matched against the image name) on all platforms and to Linux process command lines (always poll-captured). On Linux, procperf redaction is best-effort against the kernel’s 15-byte comm — reliable only for a pattern whose sensitive substring is a ≤15-byte, prefix-aligned slice of the comm; a token past byte 15 is truncated away and cannot be matched, and a Windows-style core likeoutlook.exedoes not match the extension-less commoutlook(see the procperf row in the OS compatibility matrix for the full rules and the over-suppression caveat). Patterns are matched in the stored, sanitized spelling:|and control bytes are stored as_and\/newline as\\/\n, and the operator’s pattern is put through that same transform before matching, so*evil|app*correctly redacts a process that named itselfevil|app. Redaction is acmdline+ per-app-perf control only: it never applies to the process source’snamecolumn or to netqual owner-process names — an app redacted out of$ProcPerf_*/app_perfstill appears by name in$Process_Live(and$NetQual_Liveif enabled), on every OS. macOS is names-only on both paths (ES stream and sysctl poll), so these patterns have nothing to redact there either. If your threat model depends on never storing command-line secrets on Windows/macOS, both the streaming feeder and the macOS poll guarantee that structurally.
Default redaction patterns:
["*password*", "*secret*", "*token*", "*api_key*", "*credential*"]
Patterns use case-insensitive substring matching. Leading and trailing * are optional — they are stripped before matching, so the effective match is always a bare substring (*password*, password*, and password all match any command line containing “password” in any case). Interior ? and [abc] are treated as literals, not glob metacharacters.
The built-in default patterns always apply and cannot be disabled. configure lets you add patterns, but the baseline set (password, secret, token, api_key, credential) is enforced on every collect path regardless of what is stored. If redaction_patterns is set to an array whose elements are all invalid (over-long, non-string) or to [], the built-in defaults are silently restored as the active list rather than leaving redaction disabled — so a corrupt, tampered, or empty config can never cause secrets to be written in plaintext.
To customize:
POST /api/v1/instructions/execute
{
"plugin": "echo",
"action": "configure",
"parameters": {
"redaction_patterns": "[\"*password*\",\"*secret*\",\"*token*\",\"*private_key*\"]"
}
}
Checking Echo status
The status action returns database health information:
storage_state|ok
db_health|ok|0
record_count|15234
oldest_timestamp|1710950000
newest_timestamp|1711050423
db_size_bytes|2097152
retention_days|7
config|process_enabled|true
config|process_paused_at|0
config|process_live_rows|18402
config|process_oldest_ts|1710950000
config|tcp_enabled|true
config|tcp_paused_at|0
config|tcp_live_rows|4193
config|tcp_oldest_ts|1710955000
config|service_enabled|true
config|service_paused_at|0
config|service_live_rows|812
config|service_oldest_ts|1710901000
config|user_enabled|true
config|user_paused_at|0
config|user_live_rows|97
config|user_oldest_ts|1710900100
config|module_enabled|true
config|module_paused_at|0
config|module_live_rows|0
config|module_oldest_ts|0
config|software_enabled|true
config|software_paused_at|0
config|software_live_rows|0
config|software_oldest_ts|0
config|arp_enabled|true
config|arp_paused_at|0
config|arp_live_rows|0
config|arp_oldest_ts|0
config|dns_enabled|true
config|dns_paused_at|0
config|dns_live_rows|0
config|dns_oldest_ts|0
retention_guard_declines_total|0
retention_guard_failures_total|0
config|network_capture_method|polling
config|network_capture_method_effective|polling
config|software_interval_seconds|3600
config|software_last_run_ts|1711050000
config|perf_capture_method|procfs
config|procperf_procs_seen|412
config|process_last_status|events_recorded
config|process_consecutive_failures|0
config|process_last_status_at|1711050400
config|tcp_last_status|events_recorded
config|tcp_consecutive_failures|0
config|tcp_last_status_at|1711050400
(The sample is abbreviated: <source>_last_status, <source>_consecutive_failures
and <source>_last_status_at appear once per capture source, in registry order.)
Database health (db_health). db_health|ok|<epoch> reports the epoch of
the last echo.db quarantine ever recorded on the device (0 = never);
db_health|quarantined|<epoch> is reported only by the agent process whose
startup quarantined a corrupt database (see “Corrupt-database quarantine” below).
The fact is persisted in echo_config (db_health_last_quarantine_epoch /
db_health_last_quarantine_file, basename only), so it survives restarts.
Per-source health. <source>_last_status is the terminal outcome token of
the source’s most recent collection (never_ran until it has run in this agent
process), <source>_consecutive_failures counts back-to-back failures, and
<source>_last_status_at is the epoch of that outcome (0 if never). The failure
tokens are capture_incomplete, counters_unavailable, cursor_lost,
state_unreadable, insert_failed and state_save_failed. state_unreadable means
the <source>_enabled flag (or, for software, the stored baseline) could not be
read; the tick is skipped and counted as a failure. Every other token
(including source_disabled, unsupported_platform and baseline resets) resets
the streak. The ledger is process memory only: an agent restart resets every
source to never_ran / 0, which is not a recovery. When one source hits a fatal
early return in a tick, the sources after it do not run that tick, so read
<source>_last_status_at to spot a source that is frozen by an upstream failure
rather than healthy. perf_capture_method is the registry capture method for the
running OS (procfs on Linux, ntcounters on Windows, none where there is no
perf collector), and procperf_procs_seen is the process count in the last valid
per-process counter snapshot.
Write failures. Snapshot-diff sources (process, tcp, service, user,
software, arp, dns, mapdrive) commit a tick’s events and their baseline in
one transaction, so a failed save can never leave events durable against a stale
baseline. When a tick that had events fails, the collector emits
error|<source> insert failed; when only a baseline-only save fails (no events
in the diff, the process fallback seed) it emits
error|<source> state_save_failed; the software cold-start seed reports on the
software collector’s own line, echo|collect_software|0|state_save_failed. Either way nothing was persisted and the
next tick re-derives the same delta. arp, dns and mapdrive are non-fatal:
they emit no error| line and record the token in the ledger instead.
A block is emitted for every capture source. Every source reports
<source>_enabled|true on a fresh agent — including module, software (both
shown above), procperf, netqual, netconn, arp, dns, and mapdrive; a
source reads false only after an operator disables it via configure (see the
configuration table above). netqual_capture_method reports the per-connection quality
mechanism actually in effect — inetdiag (Linux), estats (Windows once the
elevation gate has latched active), estats_pending (Windows, netqual enabled
but the first collect tick has not yet tested elevation — or netqual is off), or
none (Windows non-elevated after the access-denied latch, macOS) — so “enabled
but the agent can’t collect” is distinguishable from “off”, and a non-elevated
agent never briefly advertises estats before flipping to none.
module_live_rows stays 0 until a collector for the
host’s OS ships; likewise dns is Windows-only today (planned on
Linux/macOS), so on a Linux or macOS agent dns_enabled|true still reports
dns_live_rows|0. arp is wired on every OS (Linux via /proc/net/arp; macOS
constrained via the NET_RT_FLAGS sysctl route dump — entry_type always
unknown there), so arp_enabled|true reports real rows there — run the
compatibility action to distinguish a supported-but-empty source from a
planned-but-unimplemented one.
software_last_run_ts is the wall-clock of the last collect_software tick (0
if it has not run yet); it is the heartbeat that distinguishes “healthy but no
software changed” (where software_live_rows stays low) from “the hourly trigger
never fired”.
A <source>_enabled value can also read errored — automation that scrapes
this output should match three values, not two:
config|process_enabled|errored
errored means the stored value is not the literal true/false the agent
ever writes (corruption or tampering); the source is fail-closed until it is
re-configured. See the tri-state description below.
retention_guard_declines_total counts retention passes this agent declined
(see The retention clock guard for the four
triggers and which of them are benign), and
retention_guard_failures_total counts tables that
could not be retained at all — whether the probes could not be read or the
delete itself failed (see “The retention clock guard” below). Both
are always emitted. Read them together: a zero declines total only means the
clock is behaving if the failures total is also zero — a table whose probes fail
every pass has silently stopped being retained, and would otherwise report as
healthy. When either is non-zero, one extra line per affected warehouse table
names the table and its own count:
retention_guard|process_hourly|3
retention_guard|tcp_hourly|3
retention_guard_failed|module_hourly|2
retention_guard_failed|__clock_state__|1
retention_guard_declines_total|6
retention_guard_failures_total|2
Only tables with a non-zero count appear, so a healthy agent emits just the two
totals. __clock_state__ is not a table: it reports that the agent could not
persist its own clock reading, which leaves the guard without a comparison point
after the next restart. __implausible_now__ is also not a table: it reports
that the clock reading handed to a pass (not the stored comparison point)
was too far ahead to trust, so the whole pass declined before touching any
table — see “The retention clock guard” below. The per-table counters are in-memory and reset when the agent restarts
(the clock reading they compare against is persisted, so restarting does not
blind the guard). Scrape this across the fleet to find endpoints whose clocks
need attention — the agent has no /metrics endpoint, so this action is the
fleet-readable signal. The retention-guard counters are not yet fleet-aggregated: there is no shipped
instruction or heartbeat tag that rolls these up server-side; surfacing them via
the agent heartbeat is a tracked follow-up. (echo.db corruption, by contrast, is fleet-visible: see “Corrupt-database quarantine” below.)
The four <source>_* blocks are emitted per capture source. <source>_enabled is one of three values: true (collector active), false (disabled via configure), or errored. errored means the stored value is not a recognised boolean — configure only ever writes true/false, so an errored value indicates the agent’s echo.db was tampered with or was corrupt and re-initialised (see “Corrupt-database quarantine” below). While an errored value persists the agent applies a fail-closed policy: the affected source stops collecting (it is treated as false, not enabled) and retention skips pruning that source’s rows, so any forensic data already captured is preserved. The source stays paused until you re-issue an explicit configure for it on that device to clear the value. <source>_paused_at is 0 when the source has never been disabled and the wall-clock UTC seconds when it was last transitioned enabled → disabled. The reverse transition resets it to 0 — and this includes recovery from errored: issuing configure <source>_enabled=true on a source whose stored value is errored clears paused_at to 0 in the same transition, so a recovered source never reports enabled=true alongside a stale paused timestamp. <source>_live_rows and <source>_oldest_ts are the count and minimum timestamp of the per-source *_live table at the moment of the status call. Agents older than v0.12.0 do not emit the per-source paused_at / live_rows / oldest_ts lines. In the retention-paused list the dashboard renders a “schema older than server” badge for such an agent’s disabled source (and sorts it as the oldest, at the top of the list) rather than hiding it behind a bare —; elsewhere a missing live_rows / oldest_ts still renders —.
Corrupt-database quarantine
When an agent’s echo.db fails its PRAGMA integrity_check at startup, the agent quarantines the corrupt file rather than trusting it: the database and its -wal/-shm/-journal sidecars are renamed aside to echo.db.corrupt-<epoch> (etc.) in the same directory, a fresh empty database is initialised in its place, and the agent logs echo.db.corruption_detected. echo.db.corruption_detected is an agent-local log line (spdlog, error level); the quarantine itself reaches the server as an echo.db.corruption_quarantined audit row via the heartbeat — see “Fleet visibility” below. Consequences an operator should know:
- All Echo history on that device is reset — the new database is empty; prior events live only in the
.corrupt-<epoch>sidecar. - Per-source enable/disable state is reset to defaults — a source previously paused for forensic preservation is collecting again. After the agent recovers, re-issue any required
configuretoggles, andstatusmay briefly showerroredfor a source whose value could not be read. - The quarantined file is not auto-deleted. Recover data from
echo.db.corrupt-<epoch>before the new database’s retention overwrites the device’s storage budget — e.g.sqlite3 echo.db.corrupt-<epoch> ".recover" | sqlite3 recovered.db, or open it read-only with any SQLite tool — then remove the sidecar manually once recovered. Repeated corruption produces multiple timestamped quarantine files; none are pruned automatically, so an agent with a recurring storage fault can accumulate them — watch the data dir’s footprint.
Fleet visibility. A quarantine is reported off-device: the echo plugin
publishes heartbeat.db_corruption_total (cumulative per agent, monotonic in the
common case) and heartbeat.db_quarantine_last (<epoch>:<file>) in its plugin
storage, which the
agent forwards on every heartbeat as yuzu.plugin.echo.db_corruption_total /
yuzu.plugin.echo.db_quarantine_last. The server exposes
yuzu_fleet_echo_db_corruption_agents (see the metrics reference) and writes a
echo.db.corruption_quarantined audit row per (agent, quarantine identity),
deduplicated against the audit store and bounded to one row per agent per 10
minutes per server process (intermediate quarantines inside that window collapse
to the newest identity; another HA node can add a second row for the same
identity) — see the audit-log reference. A plugin that failed to
initialise (including Echo when the corrupt file cannot be moved aside) is reported
in the yuzu.plugins_failed heartbeat tag and yuzu_fleet_plugin_init_failed{plugin},
which distinguishes “failed to load” from “not installed”. The plugin keeps its
count in a private storage record; if that record is present but unreadable the
plugin logs ECHO: db_health.ledger unreadable; not overwriting and stops updating
the two heartbeat keys rather than resetting the count — status still reports
db_health, and the agent log carries the warning on every collect_slow tick. If
instead the record write itself fails persistently (as opposed to a read of it),
there is currently no fleet-visible signal beyond that same local warning — a
device stuck this way is silently excluded from yuzu_fleet_echo_db_corruption_agents
until the fault clears; tracked as a follow-up. The count is not monotonic in one
further edge case: a transient fault on the read that decides whether a prior
record exists (rather than the read above) is indistinguishable in-plugin from
“no prior record,” so a faulted read followed by a successful write on a later
tick restarts the count at 1 for that device rather than resuming it — the fleet
gauge counts affected agents, not events, so this narrows to a small undercount
rather than a lost signal; tracked as #5037.
Two things worth knowing about the timing of this signal. A mass event (many devices quarantining near-simultaneously, e.g. a bad storage-driver rollout) still updates the fleet gauge in real time, but individual audit rows serialize behind one another server-wide, so the audit trail can lag well behind the gauge during a burst — don’t read row latency as gauge inaccuracy. A device already quarantined before this fleet-visibility mechanism shipped has no durable epoch/basename recorded yet, so it produces no fleet signal until its next corruption event — there is no backfill of pre-existing quarantines.
If echo.db is corrupt and cannot be moved aside (read-only mount, locked file, permissions), the agent fails closed — it refuses to load Echo rather than silently trusting the corrupt database — and logs the reason. Other agent plugins continue running; only Echo is unavailable on that device until the underlying fault is cleared and the agent restarted.
network_capture_method is the configured value (which may be a value pre-staged ahead of a forthcoming kernel-event collector, or a cross-OS platform API); network_capture_method_effective is the mechanism actually collecting, which is always polling today — no kernel-event collector is wired yet, so every configured value currently collects via polling. The two are reported side by side so status can never misrepresent the active capture mechanism to a forensic analyst.
Echo dashboard page
The Yuzu dashboard includes a dedicated Echo page at /echo, reachable from the Echo entry in the sidebar’s Posture group (visible after authentication). The page is the central operator surface for Echo across the fleet. Phase 15.A delivers the retention-paused source list as the first frame; the process tree viewer and the capture sources frame sit below it. The capture sources frame locks the five core sources (process, tcp, service, user, perf), so pause or resume them with configure, and shows each as paused when the agent reports <source>_enabled=false and always on for any other reported value (an errored value still reads always on; the retention-paused list above flags it with its value-error badge). Ad-hoc Echo SQL is not on this page: it starts from Result sets (/result-sets), where From Echo query runs a SELECT on a scope and keeps the devices that answer, behind a confirm that shows the SQL.
Retention-paused source list
The first frame surfaces every device × source pair where the collector has been disabled (<source>_enabled=false). Rows are sorted paused-longest-first so devices accumulating non-aging data the longest float to the top of the list.
Columns: device hostname, source pill, paused since (UTC), paused for (coarse age), live rows count, oldest data age.
Partial result banner (#4644). The scan’s response read is capped at 10,000 rows and 50 MiB of response payload. When either cap cuts it, the page shows a “Partial result” banner above the table, the header counts (“responded”, “have all sources collecting normally”) are marked as describing only the responses that were read, and, if no paused rows are in the part that was read, the empty state says the page cannot tell and that Refresh will not change it (the cut repeats), instead of “still in progress” or “running normally”. Dropped responses read as agents that never answered, so the banner is NOT confirmation that every collector is running normally: narrow the scan to a smaller management group and scan again. A cut scan does not update the yuzu_echo_retention_paused_devices gauge (its partial row set would under-count), so the gauge keeps the last value from an uncut scan and can be stale. The same condition is result_truncated_by_cap: true in GET /api/v1/echo/retention-paused and the list_echo_retention_paused tool, which also set store_degraded: true for a cut scan so an older client sees an incomplete result.
Row states. Beyond a normal paused row (with a timestamp), the table surfaces two conditions and floats both to the top of the list so they aren’t missed:
- “schema older than server” badge — the agent reported the source disabled but sent no
paused_attimestamp, i.e. it is a pre-v0.12.0 agent that lacks the field. The row sorts as the oldest entry. Action: upgrade the agent so it records the transition time. - “value error” badge — the agent reported a
<source>_enabledvalue other thantrue/false(e.g.errored, or garbage from a corrupt or tamperedecho.db); the reported value is shown. Previously such a source was silently omitted, hiding a paused/broken collector. Action: re-configure the source (echo.configure <source>_enabled=true) or inspect the agent’secho.db; if tampering is suspected, treat the device as potentially compromised.
Workflow:
- Click Scan fleet to dispatch an
echo.statuscommand to the agents in your management-group scope. The scan-provenance header above the table reports how many agents were dispatched to, how many have responded so far, and how many have all sources collecting normally. - Review the table. A row with a high
live_rowscount or a long pause duration may indicate forensic data accumulating without being queried. - Click Re-enable on a row to dispatch
echo.configurewith<source>_enabled=trueto that single device. The row drops optimistically; click Refresh to reconcile against a fresh scan. - Click Purge data on a row to permanently drop all of that source’s stored rows (
<source>_{live,hourly,daily,monthly}) without re-enabling the collector — for “we’ve preserved what we need; reclaim the disk but keep collection paused.” Because this is irreversible you must type the device hostname to confirm. The source stays paused and the row remains (with a “Purge dispatched” note); click Scan fleet to see the counts drop to zero. If the source was re-enabled since your last scan the agent refuses the purge (source_not_paused) and nothing is deleted. If the agent cannot read the source’s enable flag it also refuses (config_unreadable: it could not confirm the source is paused) and nothing is deleted.
Purge is destructive and irreversible. It drops rows for the named source on the named device only — other sources and other devices are untouched. The typed-hostname confirmation is your last checkpoint; there is no undo.
Permissions:
- Viewing the page and the retention-paused list requires
Infrastructure:Read. - Scan fleet requires
Execution:Execute(it dispatches a fleet-wide command). - Re-enable requires
Execution:Execute(it dispatches a configure command to a single device). - Purge data requires
Infrastructure:Delete— a higher tier than Re-enable, because it is destructive. It is management-group scope-checked like Re-enable (out-of-scopedevice_idcollapses to the same 404). The “only purge a paused source” rule is enforced on the agent (it refuses withsource_not_pausedif the source is enabled, orconfig_unreadableif the enable flag cannot be read so it cannot confirm the source is paused), so a source re-enabled between your scan and the purge is never dropped. Requires an agent that supports theecho.purge_sourceaction (v0.14.0+); an older agent silently no-ops the purge — verify with a fresh Scan fleet. For theregistrysource the purge also erases its per-key baselines inecho_stateand its cursor. - The per-device Re-enable and Purge data endpoints are management-group scope-checked — it rejects out-of-scope
device_idvalues with the same 404 response as a not-connected agent (no enumeration oracle). The Scan dispatch and the rendered list, however, currently gate only on the globalExecution:Execute/Infrastructure:Readpermission: management-group confinement of these list/fan-out views is not yet effective (tracked as the ADR-0017 admit-then-filter gate) — under RBAC a confined operator is denied at the global gate rather than shown a narrowed list. When RBAC is disabled (the default), reads return the full enrolled fleet; when RBAC is enabled, list views require the global permission and per-device routes additionally enforce your management-group role assignments.
State persistence: Scan results are held in the server’s memory keyed by your username. Restarting the server clears the last-scan reference; click Scan fleet again after a restart. Persistence across restarts and multi-server coordination are planned for Phase 15.G operational hardening.
Audit trail: Every Scan emits an echo.status.scan audit event. Every Re-enable emits echo.source.reenable (with result=success and detail carrying device_id and source on success, or result=failure with detail carrying the real reason — scope_violation or agent_not_connected — on rejected attempts). Every Purge data emits echo.source.purge (same detail shape; result=denied if the Infrastructure:Delete permission is missing). Because purge dispatch is fire-and-forget, the number of rows deleted is recorded in the command’s response record, not the dispatch audit row. See docs/user-manual/audit-log.md for the full schema.
Process tree viewer
The third frame on the /echo page reconstructs a per-host process tree entirely from that host’s local Echo warehouse ($Process_Live + $TCP_Live) — no extra data is collected, and no other host is involved.
Workflow:
- Pick a live host from the dropdown (only connected agents in your scope are listed). Selecting one reconstructs the default Last 10m window.
- Choose a timescale — the preset chips On boot · On agent install · Last minute · Last 10m · Last hour · Last day, or pick or type a custom From/To (UTC) range and click Apply. Setting From == To gives a true point-in-time tree. A custom From or To that is filled in must be a valid UTC date and time (
YYYY-MM-DDTHH:MMorYYYY-MM-DDTHH:MM:SS) or, in a hand-built request, epoch seconds (up to 18 digits). Anything else, including a date that does not exist such as 31 February, is refused with a note (“The custom window is not a valid time”) and nothing is dispatched to the device. - Read the tree. Each row shows a running/exited dot, PID, name, owning user, and — when the process has connections — an inline network summary of remote
IP:portendpoints (public/internet egress is highlighted). Dozens of identical-name siblings (e.g.svchost.exe) collapse into onename ×Nrow you can expand. - Click any process to open the detail panel on the right: path, command line, user, start time, full connection list, and any anomaly evidence.
- Filter with the toolbar: All / Running / Exited, an Anomalies only toggle, and a text box that matches name, PID, or remote IP. Filters combine and apply instantly (no reload).
What “anomalies” means here: the viewer flags suspicious parent→child spawns — a common-document or browser application (Word, Excel, Outlook, Chrome, Edge, …) launching a shell or LOLBin (powershell.exe, cmd.exe, mshta.exe, rundll32.exe, …). This is computed on the server from the Echo data; it is heuristic, name-based, and deliberately conservative.
Honest limitations:
- No seed. The tree is replayed from the retained
$Process_Liveevents only. A process whosestartedevent has aged out of the live-tier cap (or that started before the oldest retained row) may not appear; the banner states the observation window. On boot/On agent installare proxies. Echo stores no boot or install timestamp, so these anchors are derived from the retained events (install ≈ oldest retained row; boot ≈ the most recent root-process start).- Windows is names-only. On Windows the process feeder is ETW (Kernel-Process), which captures image names only — so per-process path and command line are blank on Windows (they are populated on Linux/macOS). Loaded libraries/DLLs are not captured by Echo on any platform.
Permissions: viewing the frame requires Infrastructure:Read. Reconstructing a tree dispatches a read-only echo.sql to the device, so it additionally requires Execution:Execute and the device must be inside your management scope. The access is audited as echo.process_tree.read — once when the query is dispatched (recorded even if the device is offline) and again on a successful reconstruction (recording the time window, node/anomaly counts, the OS data-class, and whether connection data was shown). The device is the audit event’s target.
Forcing an immediate snapshot
The snapshot action triggers an immediate full collection of all enabled capture sources (including arp/dns), useful before a maintenance window or at the start of an investigation:
POST /api/v1/instructions/execute
{
"plugin": "echo",
"action": "snapshot"
}
Partial captures never fabricate events. If a subprocess-backed leg (systemctl/launchctl/smbstatus/journalctl/wevtutil) or a procfs/getfsstat read fails or is truncated mid-capture, that tick’s diff is skipped and the prior baseline is retained — no appeared/removed events are fabricated from a partial read. A rate-limited warning is logged and the source’s <source>_last_status reads capture_incomplete with <source>_consecutive_failures counting the streak in echo.status.
Performance impact
Echo is designed for minimal performance overhead:
- Fast collection (processes + network): typically completes in under 100ms
- Slow collection (services + users): typically completes in under 200ms
- Performance sampling: a handful of kernel-counter reads every 30s; one row written per sample. The
$Perf_Live7-day window holds ~20,000 rows (~1-2 MB) per endpoint at the default cadence. - Database size: varies by system activity; a typical endpoint generates 1-5 MB per day (plus the ~1-2 MB perf window)
- CPU: negligible between collection cycles; brief spike during snapshot + diff
- Automatic purge: old events are removed on the rollup tick (every 900 s).
Each tier’s window comes from the schema registry’s per-granularity default,
not from the
retention_daysconfigure key — that key is stored and reported bystatusbut is not what the warehouse tiers prune against. The per-tier defaults are published byGET /api/v1/settings/data-retention(echo.tiers) and shown on Settings > Data and retention (thelivetier under the Exact column) with a “Look again after” floor per source.
The retention clock guard
Time-based retention deletes rows older than now - <tier retention>, where
now is read from the endpoint’s own clock — the clock in a fleet most
likely to be wrong. A dead CMOS battery, a long suspend, a cloned VM, or a boot
before NTP converges all produce a reading that marks every row in a warehouse
table expired at once, and an unguarded delete then takes the whole forensic
window with it.
A retention pass first refuses to act at all if the clock reading handed to it is implausible (too far ahead to be trusted into arithmetic) — the whole pass declines before anything is persisted or any table is examined, and retention resumes once a sane reading arrives.
Per table, the pass then either declines (deletes nothing from that table, counts the decline) or proceeds, based on the same fact set each time — the combination of which of these four conditions hold for this table on this pass:
- it would delete every datable row of that table;
- more than a fixed 30 days elapsed since the previous pass;
- the stored reading is ahead of the current clock;
- there is no stored reading at all — the first pass after an agent upgrade or a restore.
A table declines whenever this pass’s fact set differs from what was last reported for it. An identical repeat is a suppressed, capped drain, so a warehouse that is legitimately all-expired still ages out — at the cost of one rollup tick (900 s) per distinct anomaly, not one per pass. A different anomaly arriving while one is still being worked off (e.g. the table starts wiping while a corrupt-reading decline is still active) reports again rather than deleting silently. Trigger 4 is the one exception: it is never recorded, so a table that keeps failing to persist its own comparison point declines every pass rather than being suppressed after the first — a missing comparison point is not an anomaly whose fact set can safely repeat unreported.
What to do: for 4, nothing. The reading is a single echo_config key
shared by every source and table, so it is expected once per agent — but note
EVERY enabled time-based table declines in that one pass, so the total jumps
by the table count, not by 1. It recurs only if the agent cannot WRITE that
key at all (read-only or full disk), which is the safe direction and is what
retention_guard_failures_total is for. For 2 and 3, check the endpoint’s
clock and its uptime; see
the runbook for the audit twin
of this triage.
The 30 days is absolute, not derived from the tier’s retention window: how
far the clock moved has nothing to do with how long that tier keeps rows, and
scaling it to the window put the threshold at a year on the monthly tier,
where it could never fire. Elapsed time cannot tell a jump from a dark laptop,
so the threshold sits past the point where an outage is itself remarkable — a
laptop off over a long weekend reports nothing; one off for a month declines
one tick. The reading is persisted in echo_config, so trigger 2 still fires
on the first pass after an agent restart, including one that booted with a
wrong RTC.
- Every accepted pass deletes at most 5,000 rows per table, oldest first (~480k/day/table at the 900 s cadence, far above any endpoint’s growth rate). A wipe the guard chose to allow ages out at a paced rate rather than in one statement.
- Rows timestamped implausibly far in the future — more than a day ahead, i.e. written while the clock was already skewed forward — are excluded from the “would this delete everything?” question. Without that, one such row would disarm the guard permanently.
Row-count retention is deliberately unguarded, but it is capped. The tiers that trim to a fixed row ceiling do so with no clock involved, so no clock reading can make them delete more than they always would — hence no guard and no decline. They ARE capped at the same 5,000 rows per table per pass, for a different reason: the whole retention batch runs under one held database lock, and an uncapped prune over a large excess would stall every collector on the endpoint. A big backlog now drains over a few rollup ticks instead.
A paused or errored source is skipped before the guard is consulted, so it neither deletes nor records a decline — an operator who paused a source for forensics never sees a clock-anomaly signal from it.
What this does and does not promise. The cap is the half that always
applies: it bounds every delete unconditionally. The detectors are best-effort,
and the outcome test in particular is defeated by any row written after the
clock moved — which do_rollup reliably produces, since run_aggregation runs
first and mints rows into the very tables retention then reads. That is why the
elapsed-time check is persisted. Taken together the guard converts an
instantaneous wipe of the device’s forensic window into a paced one plus a
counter; it does not guarantee every clock anomaly is detected, and a
persistently wrong clock will still drain the window over several ticks.
The operator surface is retention_guard_declines_total and
retention_guard_failures_total (plus the per-table retention_guard|<table>|<n>
and retention_guard_failed|<table>|<n> lines) in the status action, described
above. A non-zero declines total has four possible causes, listed in
The retention clock guard. One of them is benign
and expected after an agent upgrade; the rest mean the endpoint’s clock moved,
it was dark longer than the threshold, or the stored reading was ahead of the
clock. Elapsed time cannot separate those, so check the host’s time
synchronisation and its uptime history. A non-zero failures total means retention has stopped for that table
for a reason that is not the clock: either its probes could not be read, or its
delete failed. What happens to the REST of the pass depends on the error. One
that aborts the transaction itself (a disk-full, an I/O error) rolls the whole
pass back, and every table queued in it is reported — including row-count
tables, which are otherwise outside the guard. An error that leaves the
transaction intact — one table with a corrupt index, say — fails only that
table, and the healthy tables in the same pass still have their deletions
committed. That distinction exists because stopping on every error meant one
permanently-broken table halted retention for the whole endpoint, for ever.
If that rollback ITSELF fails and the database is left inside the transaction,
the agent closes the Echo database. Every later write on a connection stuck in
an uncommittable transaction would be reported durable and then lost at restart,
so it fails all of them closed instead. echo status then returns non-zero and
emits an error| line followed by storage_state|offline, and nothing else —
every config| line is withheld. Collection and retention are both stopped on
that endpoint until the agent restarts. Historical data normally stays
readable: the close affects only the read-write connection, so echo sql can
still query what was already collected. The error line says which of those two
cases you are in — the read-only connection is itself optional at open time, and
on the rare endpoint where it never opened, echo sql cannot read the history
either, so the line says that instead of promising a read path that is not there.
This needs a disk-level fault to reach, but read storage_state before trusting
any other line in that output.
In the dashboard, the capture-sources frame surfaces that error line verbatim rather than a generic failure, so the reason and the recovery advice reach the operator on the frame they opened to ask why the device’s data is missing.
Known dead band. Because the threshold is a fixed 30 days, a forward clock error smaller than that trips neither detector on a table whose own window is shorter: the step check is below its floor, and the outcome test is separately defeated by the rollup minting fresh rows before retention runs. In that band the table drains at the capped rate with no decline and no counter. The cap still bounds the damage; the detection does not fire. This is the deliberate cost of not reporting every switched-off laptop as a clock anomaly. Retention resumes automatically, paced, once the condition clears.
Upgrade note (every capture source is on by default; one-time reset of stored opt-outs). At that release all 16 capture sources then registered shipped enabled on every OS (every source added since ships enabled too). Eight sources were previously off by default:
procperf,netqual,module,software,arp,dns,netconnandmapdrive.Before you upgrade, if you paused any of the eight on purpose (for example to preserve evidence): run
echo.statuson that agent and record itsconfig|<source>_enabled|falselines (and the matchingconfig|<source>_paused_at|<epoch>lines), and export what you need withecho.sql, which reaches every table and every tier (echo.queryandecho.exportread only the live tables, and of these eight onlysoftware,arp,dnsandmapdrivehave one). The reset re-enables the source, and retention skips only disabled sources, so the exemption ends with it: from the first rollup pass after the upgrade (rollups run every 15 minutes) rows older than their tier cutoff start being pruned, and that cannot be undone. After the upgrade, re-apply<source>_enabled=falsewithecho.configurefor each source you recorded to pause it again: that stops collection and exempts the rows that remain from pruning, but rows already pruned do not come back.What the upgrade does. On the first start after upgrade each agent migrates its
echo.dbto schema version 8 once: any of those eight sources whose stored<source>_enabledvalue isfalse— including a value an operator set explicitly — is reset totrueand its<source>_paused_atmarker is cleared, in a single transaction. A value oferrored(any stored value other than exactlytrueorfalse), and the stored values of the eight sources that were already on (process,tcp,service,user,perf,power,removable,usage), are left untouched. The reset runs only once: an opt-out you write after the migration has succeeded (<source>_enabled=falseviaconfigure) sticks across restarts. A failed migration is retried on the next start, and a disable written before it succeeds is reset by the retry.If the migration fails. The success line in the agent log is
EchoDatabase: migrated to schema version 8; a failure logsEchoDatabase: v8 migration failed atfollowed by the failing step, rolls the whole migration back, and retries it on the next start. Until a start succeeds the old stored values stay in force, and a<source>_enabled=falsewritten for one of the eight in that window is reset by the retry: look for the success line before relying on a disable.What to expect.
echo.statusreads<source>_enabled|truefor all eight. New collection starts without operator action, on each OS where the source has a collector (see the OS compatibility matrix), forprocperf(which feeds the daily app-performance sync),netqual,module(Windows ETW),software,arp,dns,netconnandmapdrive(netconnandmapdrivealso run retrospective backfills bounded bynetconn_lookback_secondsand the registry/event-log history). A source whose<source>_enabledrow cannot be read at all is treated as disabled for that tick (fail-closed) and its stored rows are preserved.
Upgrade note (device perf sampling; per-app sampling is opt-in). (Superseded: every capture source now ships on by default — see the v8 note above.) On upgrade to this release, every Windows agent continues device performance sampling (
perf_enableddefaults totrue, 30-second cadence — unchanged from the prior release). Linux agents BEGIN device performance sampling on upgrade to this release: the Linuxperfcollector (procfs) is new, and becauseperf_enabledis default-ON,$Perf_Liverows start accruing on Linux hosts at the same 30-second cadence with no operator action — a real, operator-visible data-collection change on Linux fleets (setperf_enabled=falseper host to opt out; the data is device-level with no user identity, the same class as the Windows rows). Per-application sampling (procperf_enabled, now implemented on Windows and Linux) is a new, distinct telemetry category — per-app CPU/working-set by image name — and ships off by default: it is not collected until an operator opts in, because it is usage-class data subject to works-council/DPA review (seedocs/enterprise-readiness-soc2-first-customer.md). To enable it, setprocperf_enabled=truevia theconfigureaction (fleet-wide or per-device). The same applies tonetqual_enabled(per-connection TCP quality, Linux + Windows) andmodule_enabled(image-load capture) — both ship off by default. On upgrade to this releaseecho.statusnow correctly reports these opt-in sources as<source>_enabled|falseon an agent that has never set them (a previous release misreported them astrueeven though nothing was being collected); if you parseecho.statusto inventory active sources, expectmodule_enabled,procperf_enabled, andnetqual_enabledto readfalseunless you have explicitly opted in. No data collection changes on upgrade from these opt-in sources (the one collection change this release does make is Linux device perf starting automatically — see the start of this note). Warehouse tables added by a new release are now created on every database open (previously a pre-existingecho.dbmissed tables introduced after it was first created), so no manual table-creation step is needed on upgrade.
arp_enabled/dns_enabledare new this release (ADR-0015) and also ship off by default. Expect both to readfalseinecho.statuson upgrade; the new$ARP_*/$DNS_*warehouse tables are created automatically and stay empty until you opt in. ARP now collects on Windows, Linux, and macOS as of this release (previously Windows-only) — if you already setarp_enabled=truefleet-wide (e.g. while only Windows agents could collect), upgrading Linux/macOS agents starts them collecting neighbour IP/MAC records (plus interface on Linux; macOS has no interface field) at the nextfast_intervaltick, with no separate notice. Setarp_enabled=falsebefore upgrading any endpoint where that new collection is not yet approved, then re-enable once it is. DNS remains Windows-only, so there is no DNS collection change on upgrade. GUI note: after enabling a source (Capture-sources frame orconfigure), the first rows appear at the nextfast_intervaltick (default 60 s) — an empty panel immediately after enabling is expected, not a fault.
Upgrade note (new
softwaresource — OFF by default). (Superseded: every capture source now ships on by default — see the v8 note above.) This release adds thesoftwareinstall/uninstall source, which ships off by default (opt-in) — a cautious posture for a new capture source. On Windows it captures machine-wide inventory (HKLM Uninstall — asset-management / vulnerability-relevance data, machine scope only, with no user identity / no PII, like Services and User sessions). On upgrade,echo.statusgains aconfig|software_*block per agent (Windows: live values; Linux/macOS: zero rows, collector planned) plus the globalsoftware_interval_secondsandsoftware_last_run_tslines. Automation that parsesecho.statusby field count or terminal-field detection must be updated to tolerate these additional lines. The source is off by default, so opt in per host withsoftware_enabled=true. Seedocs/enterprise-readiness-soc2-first-customer.mdfor the data-handling classification.
Upgrade note (Windows process capture → ETW). BREAKING for
cmdlineconsumers. On upgrade, the Windows process source switches from the 60-second snapshot-diff poll to an event-driven ETW Kernel-Process feeder. Three operator-visible changes:
cmdlineis now empty for Windows process rows (the feeder is names-only). Any dashboard, SIEM export, or Guardian rule that relied on the Windows process command line will see an empty field after upgrade. Linux is unchanged (the poll still captures command lines there). macOS is now names-only on both paths — the Endpoint Security stream and the sysctl poll — socmdlineis empty there too (the poll blanks theproc_pidpathimage it would otherwise store). This is intentional (works-council / data-minimization posture) and not reversible by configuration — see the redaction section.- Live capture is active from the next agent start — gap-free during the live session, so short-lived processes the poll missed now appear, and
$Process_Liveholds more rows (cap raised to 100000). (One narrow seam: at the boot→live handoff, events in the window between the agent sampling its boot/live boundary and the live provider becoming active can fall in neither the backfill nor the live stream — tracked as a follow-up.) If the ETW session cannot start, the agent logs the reason and falls back to thetoolhelp32poll automatically; if the session later dies it self-heals to the poll. On Windows the death signal is immediate (the ETWProcessTracereturns). On macOS the Endpoint Security client exposes no liveness API, so the agent treats a prolonged TOTAL silence (no exec/exit for ~1h) as presumed-dead and re-arms the poll then — the threshold is deliberately long because a NOTIFY-only client cannot distinguish a dead stream from a legitimately quiet host, and a false trip drops a healthy stream to the inferior poll. The active path is reported by thestatusaction asprocess_capture_method(etworpollingon Windows;endpoint_securityorpollingon macOS). Once it has fallen back to the poll, stream capture is not re-established until the agent restarts (soprocess_capture_method=pollingon a host that should be streaming indicates a prior session failure — restart the agent to retry). Two drop counters:process_stream_droppedis the userspace ring-overflow count (the drain tick fell behind; renamed fromprocess_etw_droppedthis release, now cross-platform), andprocess_stream_kernel_droppedis the kernel/provider-side drop count (Endpoint Securityseq_numgaps on macOS; 0 on Windows ETW, which exposes no per-message sequence here).- macOS parity gap (fork-without-exec). The Endpoint Security stream subscribes
NOTIFY_EXEC/NOTIFY_EXITonly, so a child that forks but never execs is invisible until it exits (Windows ETW fires on every process create). Capturing it viaNOTIFY_FORKis deferred (#1455) — adding it naively would double-count the overwhelmingly common fork→exec case.- Boot-window backfill requires the boot AutoLogger, configured by the production InnoSetup installer (scoped to the
advancedcomponent that shipsecho.dll) and by the developer install path (install-agent-user.ps1). A reboot is required for the AutoLogger to take effect: on a fresh install, or when upgrading from a release that did not configure it, boot-window backfill (the narrow window of processes that start and exit before the agent’s session opens) is absent until after the next reboot. A minimal install that omits theadvancedcomponent does not configure the AutoLogger. Boot-window events are names-only with no user.To disable Windows process capture entirely (ETW and poll), set
process_enabled=falseviaconfigure; there is no separate “disable ETW but keep polling” switch today. While disabled, the live ETW session keeps running but its buffered events are drained-and-discarded each cycle, so no process activity from the paused window is persisted when you re-enable. As with every snapshot-diff source, re-enablingprocess_enabledclears the stored baseline, so the first cycle after re-enable does not emit ghoststoppedevents for processes that exited during the pause (it re-bases on the live process set).
- WAL mode: SQLite Write-Ahead Logging ensures reads never block writes
Warehouse Query System
Echo includes a typed data warehouse that replaces the legacy flat tar_events table with structured, tiered tables optimized for different query time horizons.
Warehouse tables
Table names use $-prefixed identifiers (e.g., $Process_Live) which the agent translates to real SQLite table names at execution time. Each capture source has multiple granularity tiers:
| Source | Live | Hourly | Daily | Monthly |
|---|---|---|---|---|
| Process | $Process_Live (100000 rows) | $Process_Hourly (24h) | $Process_Daily (31d) | $Process_Monthly (12mo) |
| TCP | $TCP_Live (5000 rows) | $TCP_Hourly (24h) | $TCP_Daily (31d) | $TCP_Monthly (12mo) |
| Service | $Service_Live (5000 rows) | $Service_Hourly (24h) | — | — |
| User | $User_Live (5000 rows) | — | $User_Daily (31d) | — |
| Power | $Power_Live (20000 rows) | — | — | — |
| Removable | $Removable_Live (20000 rows) | — | — | — |
| Perf | $Perf_Live (7d, time-based) | $Perf_Hourly (31d) | — | — |
| ProcPerf | $ProcPerf_Live (7d, time-based) | $ProcPerf_Hourly (31d, per app) | — | — |
| Module | $Module_Live (100000 rows) | $Module_Hourly (24h) | $Module_Daily (31d) | $Module_Monthly (12mo) |
| Software | $Software_Live (5000 rows) | — | $Software_Daily (31d) | $Software_Monthly (12mo) |
| ARP | $ARP_Live (5000 rows) | $ARP_Hourly (24h) | — | — |
| DNS | $DNS_Live (5000 rows) | $DNS_Hourly (24h) | — | — |
| NetQual | $NetQual_Live (100000 rows) | — | — | — |
| NetQual boot | $NetQual_Boot (400 rows — one per boot) | — | — | — |
| NetConn | $NetConn_Live (20000 rows) | — | — | — |
| MapDrive | $MapDrive_Live (5000 rows) | $MapDrive_Hourly (24h) | — | — |
| Registry | $Registry_Live (500000 rows) | — | — | — |
The $Module_* tables are registered and queryable. On Windows the module source (M2, ETW) fills them with image loads; on Linux and macOS they return zero rows until the OS-specific collector ships (M4/M5 macOS Endpoint Security, M6 Linux auditd). See echo-module-loads.md for the ladder.
The $Software_* tables are populated on Windows when the source is enabled (the default; disable with software_enabled=false); on Linux/macOS they are registered and queryable but empty until those collectors ship.
- Live tables hold the most recent raw events with a row cap (oldest rows are evicted): 5000 rows for TCP / Service / User, and 100000 rows for
$Process_Live— raised because the Windows ETW feeder is event-driven (every start/stop, including short-lived processes) and fills a 5000-row window far faster than the old 60-second poll did, so a larger raw window keeps a meaningful history before rows roll up into the hourly/daily/monthly count tiers. Exception:$Perf_Liveand$ProcPerf_Liveare time-based (7 days), not row-capped — a fixed-cadence sampler keeps a time window, so raisingperf_interval_secondsmust not shrink the history it covers. - Hourly tables aggregate counts and summaries per hour, retained for 24 hours (perf/procperf hourly: 31 days).
- Daily tables aggregate per day, retained for 31 days.
- Monthly tables aggregate per month, retained for 12 months.
- Service only has live and hourly tiers. User only has live and daily tiers. Perf and ProcPerf have live and hourly tiers. Software has live, daily, and monthly tiers (no hourly — installs are infrequent).
Key columns by table type
Process tables: ts, name, pid, ppid, cmdline, user, action (started/stopped). Aggregated tiers add start_count, stop_count. cmdline is empty on Windows (ETW) and on macOS (names-only feeders — see the OS compatibility matrix and the command-line redaction section); it is populated on Linux only. macOS is names-only on both paths (the Endpoint Security stream and the sysctl-poll fallback).
TCP tables: ts, process_name, pid, remote_addr, remote_port, local_port, proto, state. Aggregated tiers add connect_count.
Service tables: ts, name, status, prev_status, action (started/stopped/state_changed). Hourly tier adds change_count.
User tables: ts, user, domain, logon_type, action (login/logout). Daily tier adds login_count.
Perf tables: ts, cpu_pct, mem_used_pct, commit_pct, disk_read_bps, disk_write_bps, disk_read_lat_us, disk_write_lat_us, net_rx_bps, net_tx_bps (all numeric; rates are bytes/sec, latencies are µs per I/O). $Perf_Hourly carries per-hour samples, cpu_avg/cpu_max, mem_avg/mem_max, commit_avg, and avg/max throughput and latency columns. Collection is trigger-driven (echo.collect_perf, every perf_interval_seconds), so the audit trail for perf is the configure action that enables/paces it, not a per-sample dispatch record.
collect_perf status tokens. sample_recorded, baseline (first tick after start), source_disabled, unsupported_platform (no perf collector for this OS: macOS today), counters_unavailable (the OS has a perf collector but the counter read failed; the same token is used for procperf) and state_unreadable (the <source>_enabled flag could not be read; the tick is skipped and counted as a failure).
ProcPerf tables: ts, name (image name only — never a command line), instances, cpu_pct, ws_bytes. Each tick records the top 10 applications by CPU plus the top 10 by working set (union, ≤ 20 rows), aggregated across same-name processes (instances = how many). cpu_pct is the app’s share of total machine capacity — one saturated core on an 8-core box reads 12.5, matching $Perf_Live and Task Manager. $ProcPerf_Hourly aggregates per (hour, name): samples, instances_max, cpu_avg/cpu_max, ws_avg_bytes/ws_max_bytes. Apps matching a redaction pattern are never recorded. Example — yesterday’s CPU-hungriest apps on a device:
SELECT name, MAX(cpu_max) AS peak, AVG(cpu_avg) AS typical
FROM $ProcPerf_Hourly GROUP BY name ORDER BY peak DESC LIMIT 10
Software tables: $Software_Live carries ts, snapshot_id, action (installed/removed/upgraded), name, version, prev_version (populated only for upgraded), publisher, install_date — machine scope only, no user identity / no PII. The daily/monthly tiers carry day_ts (or month_ts), name, install_count, remove_count, upgrade_count. Example — everything installed or removed on a device in the last 30 days:
SELECT ts, action, name, version, prev_version, publisher
FROM $Software_Live
WHERE action IN ('installed','removed','upgraded')
ORDER BY ts DESC
NetQual tables (ADR-0020): $NetQual_Live carries ts, snapshot_id, proto (tcp/tcp6), remote_bucket (the only destination field — a coarse class: loopback/private/public/unknown; the raw address is never stored), process_name (image name only), rtt_us, rtt_var_us (jitter), lost (current-conditions loss gauge — the sort key), retrans, segs_out, ca_state (0=Open … 4=Loss). $NetQual_Boot is the per-boot retrospective baseline: ts (agent start), snapshot_id, boot_ts, window_s (the pre-Echo window length this boot), retrans_segs, segs_out, estab_resets (since-boot TCP totals), and if_in_errors/if_in_discards/if_out_errors/if_out_discards/if_in_octets/if_out_octets (non-loopback interface totals). Since-boot totals are coarse context, never a current-loss verdict.
NetConn table (ADR-0020): $NetConn_Live carries ts (the event’s own timestamp — backfilled rows predate Echo), snapshot_id, action (connected/disconnected/wifi_connected/wifi_connect_failed/wifi_disconnected/capability_changed), channel (networkprofile/ncsi/wlan), category (public/private/domain, or empty), capability (none/local/internet, or empty), iface_kind (wifi or empty), reason_code (WLAN disconnect/fail reason or NCSI change reason). Every text column is a closed enum token — no SSID, BSSID, profile name, GUID, or MAC is ever stored. Example — connectivity/Wi-Fi drops on a device, including before Echo was enabled:
SELECT ts, action, channel, capability, reason_code
FROM $NetConn_Live
WHERE action IN ('disconnected','wifi_disconnected','wifi_connect_failed','capability_changed')
ORDER BY ts DESC
MapDrive tables (capability-map §3.8): $MapDrive_Live carries ts, snapshot_id, action (appeared/removed for live snapshot-diff, historical for the init backfill), direction (outbound/inbound), local_mount, remote_path, remote_host, username, provider, and origin (live/historical). The origin column separates historically-inferred mappings (seeded once from the registry / fstab / event logs, carrying the artifact’s timestamp or 0) from live-observed ones; a currently-mapped persistent drive legitimately appears as both a historical and a live appeared row. $MapDrive_Hourly carries hour_ts, direction, local_mount, remote_path, remote_host, appear_count, remove_count. Example — every previously and currently mapped drive, both directions:
SELECT ts, direction, local_mount, remote_path, remote_host, username, origin, action
FROM $MapDrive_Live
ORDER BY ts DESC
Registry table (Windows): $Registry_Live carries ts (unix seconds), snapshot_id, hive (HKLM or HKU), key_path (below the hive; HKU paths lead with the user SID), value_name (empty for key_removed and gaps), action (added, changed, removed, key_removed, capture_gap), the NEW value as data_type/data/data_size/data_truncated, the OLD value as prev_data_type/prev_data/prev_data_size/prev_data_truncated, actor_pid, actor_image, source, observed_at (unix ms of the read that saw the change), detail and record_key. actor_pid and actor_image name the process correlated to the change (see The actor is correlated, not verified below) and are 0 and empty when none is named. detail holds the attribution marker (attr:...) on a change row and the explanation on a capture_gap row. A column that does not apply is empty or 0; an empty data_type is what tells an absent value (a removal) from a value that is present and empty. data follows registry-plugin rendering: strings as UTF-8, REG_MULTI_SZ joined with newlines, REG_DWORD/REG_QWORD in decimal, anything else as lowercase hex, capped at 4096 bytes of UTF-8 text (data_truncated = 1 when cut). source is notify when the key watch fired and reconcile when the full re-read (every 12 slow ticks, hourly at the default 300 s) found the change. A deletion is recorded as one removed row per value followed by one key_removed row for the key.
- Values are stored unmasked. Any value under a watched key, changed or not, is readable through
echo.sql: the per-key baselines Echo keeps inecho_state(registry:<root>, at most 512 bytes of text per value) hold every current value from the first read on — a Winlogon autologonDefaultPasswordis an example — and$Registry_Liveholds the old and new data of every change. A change that is still waiting for its writer to settle is held in the source’s cursor row (echo_cursor) with its old and new data, which is readable throughecho.sqluntil the next slow tick at most. A change held when the source is disabled stays in that row until the source is re-enabled (it is then writtenattr:lost-restartwith its originalobserved_at) or untilecho.purge_sourceruns on the pausedregistrysource. All of it is erased together byecho.purge_sourceon the pausedregistrysource. - The actor is correlated, not verified. The key watch finds what changed. A write-only Kernel-Registry ETW session (value writes, value deletions and key deletions; never key opens or creates) reports which process wrote a value, but its events carry the value name, type and size and not the key. Echo therefore matches each change to the writes of the same value name, type and size inside the time window in which the change must have happened. When exactly one process made such a write,
actor_pidandactor_imagename it anddetailisattr:corr(attr:corr;events:<n>when that process wrote the valuentimes in the window). That is a lead to verify against$Process_Live, not proof: a change made without a value-write event (a hive load, a restore, a transacted commit outside the window) can be correlated to an unrelated process that wrote the same name, type and size in that window. So can theremovedrow of a value in a deleted key: deleting a key produces one key-deletion event and none for its values, so each such row can be correlated to an unrelated process that deleted a value of the same name in the window (thekey_removedrow itself never names a process: it isattr:nonewhen the session is up and no loss applies, andattr:off,attr:degradedandattr:lost-restarttake precedence). So can a change whose own write event reached Echo more than about 5 seconds after the window closed without any counted loss (a starved consumer, a suspend and resume). Any local process can forceattr:ambiguouson a change by writing a value of the same name, type and size anywhere in the window, because events carry no key name; the contract then names nobody. A standard user can likewise push their own change toattr:degradedby writing values fast enough inside one tick to overflow the event buffer past the events of that change’s window (agap:lossrow records it; the change row is still written).actor_imageis the name of the process that held the pid when the event was delivered, up to about a second after the write: it is empty when that process had already exited or a newer process held the pid, and under pid reuse it can name a different process for up to five minutes. Every other outcome is marked rather than guessed, andactor_pidis0with an emptyactor_imagewhenever no process is named;0means “not named”, never “the system made the change”.detailsays why:attr:ambiguous:<n>(nprocesses wrote a matching value in the window; none is named),attr:none(the session is up, no loss applies and no matching write was seen; akey_removedrow isattr:noneon those terms whatever was written, because a key deletion carries no value name to match),attr:degraded(write events were lost during the window, seegap:loss),attr:off(the ETW session is not running: it needs an elevated agent, and without one Echo keeps the key watch alone),attr:lost-restart(the change was seen before an agent restart, or before the source was disabled and re-enabled, which discards the events that would have named its writer). When several apply,attr:lost-restartwins overattr:off, which wins overattr:degraded, which wins over the correlation outcome (attr:corr,attr:ambiguous,attr:none). - What is not recorded. A value written and set back between two reads leaves no net change and no row. A change under a key outside the curated list is invisible. A user hive that is not loaded (logged-off user) is not read. A root whose first read hits the read budget (
registry_roots_capped) is baselined from the part that was read, so the next complete read reports the remainder asaddedonce. A value larger than 4 MiB is recorded by type and size only (dataempty,data_truncated= 1,data_sizethe full size); a change that keeps its size is not detected. A root read for the first time is baselined silently: a user hive loaded at logon, or a user who logged off and back on, has itsRun,RunOnce,PoliciesandWinlogonroots baselined at their first read, so anything written to them between the hive loading and that read (up to oneslow_interval) is part of the baseline and is not a row. A watched key that is itself a symbolic link is recorded as the link (its link target value), and what the link points at is not followed, so a user who replaces their ownRunkey with a link to another key of their hive hides later writes under the target. A child key this agent is denied read access to (for example a key a user created under their ownRunorPolicieskey with a restrictive ACL) hides only itself, up to 32 such children in one root: the rest of its root is read as usual, the values stored for it earlier are kept, and deletions beneath it are not derived until it can be read again; the 33rd makes the read partial, so no deletion is derived in that root until a read sees fewer. A child that becomes denied after the root was last read is found at the root’s next read, which is the full re-read (every 12 slow ticks, hourly at the default 300 s) at the latest, because the watch reports added, removed and written keys and values but not permission changes.capture_gaprows (gap:restart,gap:disabled,gap:cursorlost,gap:baselinelost:<root>,gap:unreadable:<root>,gap:loss:<n>) mark the stretches where capture was interrupted, a baseline was lost, part of a root could not be read, or write events were lost: agap:unreadable:<root>row is written when a root cannot be read (the root itself, or any key beneath it other than an access-denied child), and when a child key under a root is newly denied (the set of denied child keys gains a member; itsdetailnames the first few); only an access-denied child is isolated, and every other read failure beneath a root makes the whole root unreadable. Nothing is written while the situation holds steady, shrinks or clears. Agap:loss:<n>row is written whennwrite events were lost since the previous tick (the event buffer overflowed, the kernel session dropped events, an event could not be decoded, or the session could not be queried); every change whose time window overlaps the loss is then writtenattr:degradedinstead of naming a process. - Timing. A change is read at the slow tick that sees it. Its row is written in that same tick once its writer is settled, which means the write events for its time window have all arrived (they can trail the write by a few seconds). A change whose window closed less than about 5 seconds before the tick is held and written at the next tick instead, and so is every change found without a key-watch notify (a root with no live watch, or the hourly full re-read), because its window runs to the end of the read. The exception is a change whose marker cannot depend on a later event (
attr:off,attr:lost-restart): it is written in the tick that sees it, so after an agent restart the whole re-read is written at once. A row therefore lands within twoslow_intervals of the change (600 seconds by default), usually within one.observed_atis when the read saw it, not when it was written to the registry. - Health.
echo.statusreportsregistry_capture_method(notify+etwwhile the ETW session runs,notifywhen it does not,nonebefore the first tick),registry_watch_roots,registry_watch_roots_unarmed,registry_roots_unreadable,registry_roots_capped,registry_etw_events(write events the session delivered since the agent started),registry_etw_dropped(events evicted from the 65536-event buffer),registry_etw_lost(events the kernel session or the decoder lost, plus failed session queries), andregistry_attr_attributed,registry_attr_ambiguousandregistry_attr_none(rows written with a named process, with several candidates, and without a named process for any reason other than ambiguity:attr:none,attr:off,attr:degradedandattr:lost-restart, since the agent started). A root that is unreadable or capped keeps its last good baseline and is retried; it is never reported as removed, and a root that cannot be read at all is also reported once as agap:unreadable:<root>row. A root is capped when its read hit the read budget or when a key under it was written to while it was being read (a partial read derives no deletions; the root is read again at the next tick). A root that was read completely once keeps the event buffer from releasing events newer than that read, so an idle root holds it back by up to an hour (every root is re-read hourly) and one that then stays unreadable or capped holds it back for as long as it does. The buffer holds 65536 events: a root last read an hour ago fills it at about 18 registry writes per second across the host, and one last read ten minutes ago at about 110 per second. Past that the oldest events are evicted,registry_etw_droppedrises on every tick,gap:lossrows recur, and the changes the hourly re-read finds are writtenattr:degraded. Ifregistry_etw_droppedkeeps rising, checkregistry_roots_unreadableandregistry_roots_cappedfirst: a root stuck in either state is the one holding the buffer back furthest.registry_watch_roots_unarmedcounts roots with no live watch: a root whose key does not exist (for example most of the per-user keys of theS-1-5-18,S-1-5-19andS-1-5-20service accounts) and any root past the 62-watch limit, which are read on every full re-read instead. It is normally non-zero on some hosts; the signal that the watcher itself has stopped isregistry_watch_roots_unarmedequal toregistry_watch_roots. The eleven keys are written only when a value changes, so on a quiet host they look frozen. To tell a quiet source from a stalled one, readregistry_last_collect_ts(epoch seconds of the last committed tick;SELECT value FROM echo_config WHERE key = 'registry_last_collect_ts'throughecho.sql), which should be no older than a fewslow_intervals, and the collect output, which carriesecho|collect_registry|0|capture_incompleteon a tick that could not complete (also reported asregistry_last_statusinecho.status). The ETW session is namedYuzuEchoRegistryEtw-<agent pid>and outlives an agent that crashed or was killed, holding one of the host’s limited trace-session slots until it is stopped or the host restarts; only an agent that gets the same pid again stops it by itself.logman query -etslists theYuzuTar*sessions andlogman stop <name> -etsremoves a leaked one. An agent that ran the key-watch-only build without elevation stays that way after the upgrade:registry_capture_methodstaysnotifyand every row saysattr:offuntil the agent runs elevated.
Querying with SQL
Use the echo.sql action to execute SELECT queries against warehouse tables:
POST /api/v1/instructions/execute
{
"plugin": "echo",
"action": "sql",
"parameters": {
"sql": "SELECT name, pid, cmdline FROM $Process_Live ORDER BY ts DESC LIMIT 50"
}
}
Pre-built queries are available in content/definitions/echo_warehouse.yaml, covering common use cases like recent processes, TCP connections by process, listening ports, hourly summaries, service state changes, user sessions, and process trees.
Rollup aggregation
The warehouse automatically runs a rollup aggregation cycle every 15 minutes. Each cycle:
- Aggregates live-tier data into hourly summaries
- Aggregates hourly data into daily summaries
- Aggregates daily data into monthly summaries
- Enforces retention limits on each tier
To force an immediate rollup, use the echo.rollup action.
Safety controls
SQL queries are validated at multiple levels:
- Server-side: Only SELECT statements are permitted. A keyword blocklist rejects INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, ATTACH, DETACH, PRAGMA, VACUUM, and REINDEX.
- Agent-side: Queries run on a dedicated read-only SQLite connection guarded by a
sqlite3authorizer. OnlySELECTagainst registry-known warehouse tables is permitted (reference them by the$-prefixed names:$Process_Live,$TCP_Hourly, etc.);PRAGMA,ATTACH, schema-table reads (sqlite_master), writes, and recursive CTEs are denied at prepare time.$-prefixed names are translated to physical names only outside string literals and comments. Only a single statement is allowed, and queries exceeding 4KB are rejected. A blocked query returnsquery rejected: operation or table not permitted— for schema discovery use the$-prefixed warehouse table names rather thanPRAGMA/sqlite_master.
Data storage
Echo events are stored in {data_dir}/echo.db (SQLite), where data_dir is the agent’s configured data directory. The database uses:
- WAL journal mode for concurrent performance
busy_timeout=5000for thread safetysecure_delete=ONto zero deleted data- On Linux and macOS,
echo.dband its-wal/-shm/-journalfiles are set to owner-only (0600) each time the database is opened. On Windows the agent creates or re-secures the same four files with a protected DACL before SQLite opens them (SYSTEM, Administrators and the agent service account only, no inheritance fromC:\ProgramData\Yuzu), so local standard users cannot read them; an upgraded database is re-secured on its first open. A file in that set owned by any other account, or carrying more than one hard link, is treated as planted: a sidecar is removed and re-created (only its own name in the state directory is removed), and a foreign-owned or hard-linkedecho.dbstops the open, with the file, its owner and the remedy in the agent log. The owner test needs a volume with ACLs: where the owner cannot be read (FAT, exFAT) it is no evidence and refuses nothing. For a foreign-ownedecho.db, do not adopt it: moveecho.dband its-wal,-shmand-journalfiles aside or remove them, then restart the agent (the history in them is lost). The same applies when the agent now runs as a different account from the one that created the files and that former account is neither SYSTEM nor an administrator: the files stay owned by it, andicacls <file> /resetdoes not change an owner. Adopt them only after comparing the owner SID the log names with the former agent account’s SID (a local standard user’sS-1-5-21-…-1xxxis the planted case) and finding that same owner on all four files; otherwise move the set aside. To adopt them, an administrator takes ownership of the four files and grants the new account full access (this sequence has not yet been verified on a Windows host). When LocalSystem or an administrator account created the files, no owner is named, because those owners pass the owner test: the log showscould not open <file> to secure it (error 5)followed byfailed to open echo.db, and the remedy is to move the four files aside or have an administrator grant the new account full access (also not yet verified on a Windows host). For a hard-linked file, remove the other link, whichfsutil hardlink list <file>names. A file whose permissions deny the agent the right to change them gets the same two checks and keeps its permissions if it passes. A handle opened before the upgrade keeps its access until it is closed. Every other file in the state directory keeps the directory’s inherited ACL (seedocs/agent-privilege-model.md) - Indexes on
timestamp,(event_type, timestamp), andsnapshot_id
Upgrading from the pre-rename database
Echo was named TAR before 2026-10, and its database was {data_dir}/tar.db. On its first start after the upgrade the agent adopts that file before any plugin loads: it moves tar.db-journal, tar.db-wal and tar.db-shm to the echo.db names first and tar.db last, so an interrupted adoption simply resumes on the next start. The Echo plugin then renames the file’s tar_state, tar_config and tar_cursor tables to echo_* (schema version 9) in one transaction. Nothing is copied and the file keeps its owner-only permissions, so no history or operator setting is lost, including a source an operator paused.
- If
echo.dbandtar.dbboth exist (an agent downgraded after the rename and then upgraded again), they are two independent databases. The agent usesecho.db, leavestar.dband its sidecar files untouched and logs one warning that names them; rows the older agent wrote totar.dbin the meantime are not merged. - If
tar.dbexists and cannot be adopted (a read-only data directory, for example), the agent logs the error and the Echo plugin refuses to start rather than creating a fresh, emptyecho.dbbeside it; a fresh file would re-enable every source an operator had paused. - Old
tar.db.corrupt-*quarantine files are not touched and can be deleted once they are no longer needed. - The Echo plugin’s stored health history (its key-value namespace) is re-keyed from
tartoechoat the same time.