kenny Wire Protocol (v0.22)¶
Single source of truth. This document and the JSON files in
docs/fixtures/define the contract betweenkenny-server(Python) andkenny-agent(Rust). Both sides validate against the same fixtures. Do not copy schemas intoCLAUDE.md— link here instead. Changes to this contract are a synchronization point: bump the version, update fixtures, then update both implementations.
Transport¶
kenny-agentopens an outbound WebSocket (WSS in production) tokenny-serverat/agent/ws. The agent never listens for inbound connections.- All frames are UTF-8 JSON objects, one frame per WebSocket text message.
- Claude talks to
kenny-serverover MCP (Streamable HTTP). That MCP layer is separate from this agent⇄server wire protocol; MCP tool calls are translated by the server intorequestframes on the tunnel. - Authentication on this tunnel is mutual and per-agent, using Ed25519 signatures
layered over the (TLS) transport (ADR-0022). Each agent holds its own Ed25519 keypair
(private key never leaves the device); the server stores that agent's public key. The
server holds one server-wide Ed25519 keypair whose public half is pinned in the
agent at install time. Right after connect the two sides run a three-message
challenge-response (
register→challenge→auth, below): the server proves its identity to the agent (defeating server spoofing — an attacker who terminates/MITMs TLS cannot pushrequestframes because it cannot sign the agent's nonce), and the agent proves its identity to the server. The operator authenticates to the server (MCP endpoint + web UI) with a separate operator token (see ADR-0008), unrelated to this handshake. - Migration window: during rollout a server may still accept the legacy per-agent
bearer
register.token(symmetric) whenKENNY_ALLOW_TOKEN_AUTH=1; the signature path is selected wheneverregister.protocol >= "0.8"andregister.client_nonceis present. The token path is removed at cutover. See ADR-0022 and ADR-0014.
Frame envelope¶
Every frame has a type field. Known types:
| type | direction | shape (see below) |
|---|---|---|
register |
agent → server | identifies the agent right after connect |
challenge |
server → agent | server's signed nonce (mutual-auth step 2) |
auth |
agent → server | agent's signature (mutual-auth step 3) |
request |
server → agent | invoke one capability tool |
response |
agent → server | result/error for a request (by id) |
telemetry |
agent → server | periodic pushed snapshot (no request) |
log |
agent → server | a forwarded structured log event |
ping |
both | heartbeat |
pong |
both | heartbeat reply |
register (agent → server)¶
{
"type": "register",
"agent_id": "example-pc",
"protocol": "0.8",
"client_nonce": "<base64, 32 random bytes>",
"meta": { "hostname": "EXAMPLE-PC", "os": "windows", "version": "0.1.0", "arch": "x86_64", "channel": "stable" }
}
os ∈ {windows, linux, macos}. arch ∈ {x86_64, aarch64} is the agent's
normalized CPU architecture (from std::env::consts::ARCH, collapsing arm64 to
aarch64); the server uses it to select the matching self-update binary. Legacy agents
that omit it are treated as x86_64. channel ∈ {stable, dev} is the release
channel this binary was built from (KENNY_AGENT_CHANNEL at build time, default
stable; see ADR-0048) — it is the agent's actual channel, distinct from the
desired channel an operator sets per agent server-side (dashboard//api/agents/{id}),
which decides what a dev-channel update campaign is allowed to target. Legacy agents
that omit it are treated as stable. protocol is the agent's PROTOCOL_VERSION;
client_nonce is 32 fresh random bytes (base64) that the server must sign in the
challenge. The server looks up agent_id and replies with a challenge (it never
registers the connection until the agent's auth verifies).
token (a per-agent bearer secret) is optional and legacy: it is only honoured
during the migration window (KENNY_ALLOW_TOKEN_AUTH=1) and only when protocol/
client_nonce are absent, in which case the server authenticates the token against its
per-agent token store and registers immediately (no challenge/auth). On failure the
server closes the socket with a non-1000 code (4401).
challenge (server → agent)¶
{
"type": "challenge",
"server_nonce": "<base64, 32 random bytes>",
"server_sig": "<base64 Ed25519 signature over the transcript>"
}
Sent in reply to a signature-path register. server_sig is the server's Ed25519
signature, made with the server-wide private key, over the transcript (below). The
agent verifies server_sig against its pinned server public key. If verification
fails — or the frame is not a challenge — the agent aborts the session, sends no
auth, dispatches no request, and reconnects. This is the anti-spoofing guarantee:
only the holder of the server private key can answer the agent's fresh nonce.
auth (agent → server)¶
Sent only after the agent has verified server_sig. agent_sig is the agent's Ed25519
signature, made with its per-agent private key, over the same transcript. The server
verifies it against the agent's stored public key; on success it registers the connection
under agent_id and proceeds (pushes policy, accepts request frames). On failure it
closes the socket with 4401.
Transcript (signed by both sides)¶
Both signatures cover the same byte string, constructed identically on both sides
(0x00 is a single NUL separator byte; nonces are the raw 32 bytes, not their base64):
transcript = "kenny-mutual-auth-v1" (20 ASCII bytes, domain-separation label)
|| 0x00
|| agent_id (UTF-8 bytes)
|| 0x00
|| client_nonce (32 raw bytes, from register)
|| 0x00
|| server_nonce (32 raw bytes, from challenge)
Binding both nonces and agent_id into both signatures prevents replay and reflection.
Ed25519 public keys, private seeds, signatures, and nonces are exchanged as standard
base64 (with padding). Deterministic golden vectors live in
docs/fixtures/vectors/mutual_auth.json; both implementations verify against them so the
transcript stays byte-identical across Rust and Python.
Enrollment (first contact)¶
An agent generates its keypair locally on first run; its public key reaches the server
once via a one-time enrollment token carried by the installer (over TLS):
POST /api/agents/{id}/enroll with { "public_key": "<base64>" }, authorized by the
enrollment token. The server records the public key bound to agent_id (the token is
single-use). Thereafter only signatures authenticate. The installer also carries the
pinned server public key. See ADR-0022.
request (server → agent)¶
{
"type": "request",
"id": "9f1c0e2a-...",
"tool": "powershell_exec",
"args": { "script": "Get-Process | Select -First 5", "timeout_s": 30 }
}
id is a server-generated UUID. tool is one of the names in the tool catalog
below. args matches the per-tool schema.
powershell_exec and shell_exec are OS-specific mirrors of each other — the former
runs a script via powershell.exe on Windows, the latter a command via sh -c on
Linux/macOS. Each is unsupported on the other OS (see below), and the server refuses
to even forward the wrong one for a given agent (see "OS-scoped tools" below); Claude
picks the right one from the agent's os, which list_agents/select_agent already
report.
response (agent → server)¶
Success:
{ "type": "response", "id": "9f1c0e2a-...", "ok": true,
"result": { "stdout": "...", "stderr": "", "exit_code": 0 } }
Error:
{ "type": "response", "id": "9f1c0e2a-...", "ok": false,
"error": { "code": "timeout", "message": "tool exceeded 30s" } }
error.code ∈ {timeout, not_found, exec_failed, unsupported, bad_args,
internal, disabled, blocked, paused}. unsupported is returned by an agent that lacks the
capability on its platform (e.g. winget_list on a Linux dev build, powershell_exec on
Linux/macOS, or shell_exec on Windows). disabled is
returned when the agent is online but the person at the endpoint has switched remote
control off locally (via the agent's tray menu): the agent then refuses every
mutating tool (powershell_exec, shell_exec, winget_install|uninstall|update,
net_dns_flush, net_adapter_reset, agent_update, webfilter_apply|clear,
remotehelp_start|stop,
account_set_enabled|set_admin|set_logon_rights|create|delete|session_action,
password_policy_set) while
telemetry and read-only diagnostics keep working. Remote control is on by default and the choice persists
across restarts. See ADR-0011.
OS-scoped tools¶
powershell_exec and shell_exec each require a specific agent OS family (windows
and linux/macos respectively). Before forwarding such a tool, the
server
compares the requested tool's required family against the target agent's known os
(from register.meta.os, see "Frames" above) and, on a mismatch, refuses the call
itself with error.code = "unsupported" and a message naming the correct tool —
without ever sending a request frame to the agent. This is a deterministic,
server-side routing guard, not a security boundary: it exists so a wrong-OS call fails
fast with an actionable message instead of round-tripping to the agent (which would
also refuse it, per the paragraph above, once code reaches dispatch::run). An agent
unknown to the registry (e.g. selected from stored telemetry only) skips this
pre-check; the call still fails once attempted, either at the tunnel (agent offline) or
at the agent's own OS-gated handler.
The account-governance tools are deliberately not OS-scoped (they were, in v0.15).
They are served on every OS family, and what a particular account on a particular host
can do is discovered from the local_accounts inventory instead — see "Account
governance tools" below. A whole-tool OS scope would have forced the caller to know the
host's OS before acting; the per-account capability map is finer, truer, and already the
mechanism ADR-0042 chose for the local-vs-Microsoft asymmetry.
paused is returned when the agent is online but has voluntarily stepped back because a
protected game is running on the endpoint (the agent detected the game's anti-cheat process).
To avoid being mistaken for cheating software, the agent suspends its most anti-cheat-visible
tools — today screen_capture — while the game runs, and relaxes the periodic process/port
enumeration (those telemetry sections report a paused summary and stop listing processes).
Unlike disabled, this is automatic and game-scoped rather than an operator toggle, and it
clears the moment the game exits. The step-back is transparent by design: the agent genuinely
stops the visible action and reports it — it never hides, renames, or disguises what it does.
See ADR-0035.
blocked is returned by the agent's deterministic, always-on safety guard: a
compiled-in policy that refuses individually dangerous calls (e.g. a powershell_exec
script that deletes volume shadow copies, clears event logs, or disables Defender; a
shell_exec command that runs rm -rf / or a fork bomb; an
fs_read of the SAM hive; an agent_update from a non-allowlisted host) regardless of
operator approval or kill-switch state. Unlike disabled, the guard cannot be turned off
remotely and is not a substitute for the operator confirm-gate (ADR-0009) or the
local kill-switch (ADR-0011); it is a last-line, defense-in-depth refusal sitting below
them. The message names the matched rule. See ADR-0019.
The guard's built-in rules ship as a shared deny-rule catalog (docs/policy/deny_rules.json):
the agent embeds it at build time and the server loads the same file for an optional
best-effort mirror that can refuse a call before forwarding (earlier feedback). The
agent remains the authoritative enforcement point. Operators may add — but never remove —
deny rules on top of the built-ins; those extra rules are delivered to the agent via the
policy frame below. See ADR-0020.
blocked is also the refusal when a shell call is outside what the fleet's shell execution
mode permits (policy.shell below): the deny rules say what must never run, the mode says
what may run at all. A deny rule names itself in the message; a mode refusal names the mode.
See ADR-0064.
policy (server → agent)¶
After a successful register (and again whenever the operator changes the list), the
server pushes the operator's append-only extra deny rules to the agent. These are
additive to the agent's compiled-in built-ins (which can never be weakened or removed by
this frame). An empty rules array clears the operator additions but leaves the built-ins
intact.
{
"type": "policy",
"rules": [
{ "id": "op_block_choco", "applies_to": "powershell",
"pattern": "(?i)\\bchoco\\b", "reason": "operator: block chocolatey" }
]
}
Each rule has id (stable identifier), applies_to ∈ {powershell, posix, self_protection,
path}, a pattern (regex in the portable subset common to Rust regex and Python re —
no backreferences/lookaround), and a human-readable reason. The agent recompiles its rule
set on each policy frame; a rule whose pattern fails to compile is skipped (logged), never
fatal. The same {id, applies_to, pattern, reason} shape is used by the shared catalog.
policy.shell — the fleet's shell execution mode¶
The same frame carries the fleet's shell execution mode, which decides the default verdict
for powershell_exec and shell_exec. Deny rules answer "what must never run"; the mode
answers "what may run at all". See ADR-0064.
{
"type": "policy",
"rules": [],
"shell": {
"mode": "allowlist",
"allow": [
{ "id": "al_get_service", "applies_to": "powershell",
"pattern": "(?i)Get-Service( [A-Za-z0-9_.-]+)?", "reason": "read service state" }
]
}
}
shell is optional. Omitted, the agent keeps the mode it already holds; an agent that has
never been told one runs unrestricted. mode is one of:
| mode | verdict for powershell_exec / shell_exec |
|---|---|
unrestricted |
run it, subject to the deny rules. The default, and the pre-0.18 behaviour. |
allowlist |
run it only if the command fully matches an allow entry; otherwise blocked. |
off |
always blocked. |
allow entries reuse the {id, applies_to, pattern, reason} shape above. Only
applies_to ∈ {powershell, posix} is honoured — powershell entries are consulted for
powershell_exec, posix entries for shell_exec; self_protection and path entries are
ignored (and logged), because an allow rule for a surface that has no command string means
nothing.
Four properties bind every implementation of this mode:
- Deny outranks allow. Deny rules — built-in and operator — are evaluated first and always.
An
allowentry can never lift a rule from the shared catalog. allowpatterns match the whole command, never a substring. The command string is trimmed of leading/trailing whitespace and must match a pattern in its entirety. A substring match would make anallowentry ofGet-ProcessadmitGet-Process; rm -rf /. Implementations that match on substrings by default (Rust'sregex::is_match) anchor the pattern as^(?:…)$at compile time. Write the tokens of anallowpattern with a literal space, not\s:\salso matches a newline, which both shells treat as a command separator, soGet-Service(\s+\S+)?admitsGet-Service+ newline +Stop-Computer.- An empty
allowlist undermode: "allowlist"blocks every shell call. Fail-closed: removing the last entry is not the same as leaving the mode. - The refusal is
blocked, notdisabled.disabledremains the agent-local kill switch (ADR-0011). Themessagenames the mode or the rule that refused the call.
The mode is fleet-wide: every connected agent is pushed the same one. The frame is per connection, so a future per-agent override changes only how the server resolves what to put in this field — it is not a change to this contract.
An agent predating v0.18 ignores shell and keeps running unrestricted. For those agents the
server's mirror (below) is the only place the mode is enforced.
policy.collect — per-host telemetry collection gate¶
The same frame carries collect, a map of telemetry section name → whether this agent
collects it. It exists so that a section the server has no use for on a given host is not
gathered on that host at all, instead of being gathered and then discarded. Today the only
honoured key is web_activity; unknown keys are ignored.
collectis optional. Omitted, the agent keeps what it holds; a key absent from a present map is likewise left as it is. An agent that has never been told collects every section — the pre-0.20 behaviour.- Unlike
shell,collectis per host: the server resolves it from that host's web-filter configuration (ADR-0069), so two connections can be pushed different values. The server re-sends the frame to a host whenever the resolved value changes. - A section switched off still appears in every snapshot and
telemetry_collectresult as a stub with the section's normal shape, empty data,status: "ok"and the summarynot collected (server policy). The gate sits in the collector, so the scheduled push and the on-demandtelemetry_collectpath cannot disagree. - The agent treats
collectas data minimisation, not as authority: the server does not rely on it. It discards what it did not ask for on insert, which covers agents predating this version and any snapshot taken before the firstpolicyframe arrived.
telemetry (agent → server, pushed)¶
The agent pushes a snapshot on a timer (default every 900 s; the server may send
the interval in a future register ack — not in v0.1). A snapshot is a map of
section name → section payload. Every section payload carries status and
summary plus section-specific fields, so the server can aggregate fleet health
without domain logic.
{
"type": "telemetry",
"agent_id": "example-pc",
"collected_at": "2026-06-04T18:00:00Z",
"snapshot": {
"disk": {
"status": "warn",
"summary": "C: 91% full",
"volumes": [
{ "mount": "C:", "total_bytes": 511000000000, "free_bytes": 46000000000, "percent_used": 91 }
],
"top_dirs": [
{ "path": "C:\\Users\\testuser\\Videos", "bytes": 120000000000 }
]
},
"defender": {
"status": "crit",
"summary": "Real-time protection OFF",
"enabled": false,
"realtime_protection": false,
"last_scan": "2026-05-01T03:00:00Z",
"last_scan_type": "quick",
"last_signature_update": "2026-05-20T06:00:00Z",
"threats_found": 0,
"action_needed": true
}
}
}
A telemetry_collect request (see tool catalog) returns the same snapshot
shape inside response.result, optionally restricted to args.sections.
log (agent → server, pushed)¶
The agent forwards its own structured log events (from tracing) to the server so
operator-visible events survive when the agent runs as a Windows service and its
stderr is discarded. The agent emits one frame per event for events at or above a
configurable level (KENNY_LOG_FORWARD_LEVEL, default info); the agent still
writes a fuller record to a local rotating file. Forwarding is best-effort: while the
agent is disconnected, events accumulate in a bounded buffer and the oldest are
dropped under pressure — log frames are never retried like a request.
{
"type": "log",
"agent_id": "example-pc",
"at": "2026-06-04T18:00:01Z",
"level": "warn",
"target": "kenny_agent::tunnel",
"message": "tunnel error; backing off",
"fields": { "error": "connection reset", "backoff_secs": 4 }
}
level ∈ {error, warn, info, debug, trace}. at is an RFC 3339 / ISO 8601
timestamp. target is the emitting module path. fields is an optional object of
structured key/values captured from the event (absent when the event has none). The
server persists these alongside its own log records and the tool-call audit (see
ADR-0017); they are never forwarded to an agent.
ping / pong¶
Either side may send ping; the peer replies pong. The agent sends a ping every
30 s. The server closes the connection and marks the agent offline if no frame (any type)
arrives within 3 missed intervals (90 s), so a half-open socket cannot keep a host
"online".
Tool catalog¶
The server exposes each tool as an MCP tool; the agent implements a handler with the same
name. Argument keys below are exact and are exactly what reaches the agent in the request
frame's args.
On the MCP surface, every forwarded capability tool additionally accepts an agent_id
argument naming the target host (ADR-0038). It is server-consumed routing metadata: the
server pops it off the call's arguments to pick which agent connection to send the request
frame down, so it is never included in the wire args and the agent never sees it — the
table below is unaffected and unchanged. agent_id is required on this path (two concurrent
MCP sessions authenticated with the same credential have no other reliable way to stay
isolated); select_agent remains available for discovery but no longer decides where a
forwarded call lands.
| tool | args | result (sketch) |
|---|---|---|
powershell_exec |
{script, timeout_s} |
{stdout, stderr, exit_code} |
shell_exec |
{command, timeout_s} |
{stdout, stderr, exit_code} |
fs_list |
{path} |
{entries:[{name,is_dir,bytes}]} |
fs_search |
{root, pattern} |
{matches:[path]} |
fs_read |
{path} |
{content, truncated} |
fs_disk_usage |
{} |
{volumes:[...]} |
winget_list |
{} |
{packages:[{id,name,version,available}]} |
winget_install |
{id} |
{ok, log} |
winget_uninstall |
{id} |
{ok, log} |
winget_update |
{id?} |
{ok, log} |
diag_processes |
{} |
{processes:[{pid,name,cpu,mem_bytes}]} |
diag_services |
{filter?} |
{services:[{name,display,status,start}]} |
diag_eventlog |
{log, count} |
{events:[{time,level,source,message}]} |
diag_autostart |
{} |
{entries:[{name,command,location}]} |
net_config |
{} |
{interfaces:[...], dns:[...]} |
net_dns_flush |
{} |
{ok} |
net_adapter_reset |
{name} |
{ok} |
screen_capture |
{} |
{image_b64, format:"png"} |
remotehelp_status |
{} |
{installed, version, internet_ok, interactive_session} |
remotehelp_start |
{} |
{launched, pid, note} |
remotehelp_stop |
{} |
{stopped} |
telemetry_collect |
{sections?} |
snapshot map (see telemetry frame) |
agent_update |
{version, url, sha256} |
{ok, staged_version} |
webfilter_status |
{} |
{active, entry_count, list_hash, doh_policy, applied_at, supported} |
webfilter_apply |
{domains, doh_policy, list_hash} |
{ok, applied, doh_policy_applied, list_hash, applied_at} |
webfilter_clear |
{} |
{ok, removed_entries, doh_policy_cleared} |
account_set_enabled |
{principal, enabled} |
{ok, principal, kind, enabled} |
account_set_admin |
{principal, admin} |
{ok, principal, kind, admin} |
account_set_logon_rights |
{principal, deny} |
{ok, principal, kind, deny} |
account_create |
{name, password, display_name?, admin?} |
{ok, principal, kind} |
account_delete |
{principal, remove_profile} |
{ok, principal, profile_removed} |
account_session_action |
{principal, action, warn_seconds?} |
{ok, principal, action, sessions} |
password_policy_set |
{min_length?, max_age_days?, lockout_threshold?} |
{ok, applies_to:"local_only", policy} |
agent_update is a server-triggered self-update (state-changing): the agent
downloads the new binary from url (served by the server's download endpoint),
verifies it against sha256, stages it, and restarts itself (as a Windows service)
into the new version. The agent answers {ok, staged_version} before restarting, so
the connection drops and the agent reconnects on the new version (compare
register.meta.version). On a non-Windows/dev build the agent returns
error.code = "unsupported".
The remotehelp_* tools orchestrate Windows Quick Assist as a remote-help
concierge: kenny prepares and brokers a session but does not carry the screen or
input itself (Quick Assist brings its own Microsoft relay, NAT traversal, and
encryption). remotehelp_status is read-only — it reports whether Quick Assist is
installed (installed, version from Get-AppxPackage), whether the internet is
reachable (internet_ok), and whether an interactive user session is present to host the
app (interactive_session). remotehelp_start and remotehelp_stop are mutating:
start launches Quick Assist on the interactive user desktop and answers
{launched, pid, note} (the note reminds the operator that a human helper must supply
the Quick Assist code and the person at the PC must accept); stop terminates Quick
Assist ({stopped}) so no session lingers. Because the agent runs as a session-0 service
with no desktop, start launches the app via the user-session tray helper over a local
named pipe, restricted to an allow-list of remote-help executables — same delivery
mechanism as screen_capture (ADR-0018). On a non-Windows/dev build start/stop
return error.code = "unsupported" and status reports everything not-available. See
ADR-0021.
The webfilter_* tools implement parental-controls blocking (ADR-0024). They are the
enforcement half of the web_activity telemetry section (below); the alarm path does not
depend on them. The server owns the per-host list and pre-merges the effective block set into
a flat domains array — the agent is a dumb, idempotent enforcer and carries no list logic.
webfilter_status({}) is read-only (works under the kill switch, likeremotehelp_status):{active, entry_count, list_hash, doh_policy:{chrome,edge,firefox}, applied_at, supported}, wherelist_hashis the agent's recomputed hash of the currently applied block (for drift detection against the server's intended hash) anddoh_policyreports the current per-browser DNS-over-HTTPS policy state.webfilter_apply({domains, doh_policy, list_hash}) is mutating: it writesdomainsas a marker-delimited block (# kenny-webfilter begin/end, one0.0.0.0 <domain>line each) into the OS hosts file via atomic replace, and — whendoh_policy == "disable"— sets registry policies turning DNS-over-HTTPS off in Chrome, Edge, and Firefox so DoH cannot bypass the hosts block; then flushes the DNS cache.domainsare normalized lowercase host names, hard-capped at 10 000 (the agent returnsbad_argsabove the cap rather than silently truncating — a server/agent cap mismatch must surface).doh_policy∈ {"disable","leave"}.list_hashis the server'ssha256(sorted domains)[:16], echoed back in the result and bywebfilter_status. The agent refuses (blocked) any list that would blackhole a self-protected name (localhost, the configured server host, core Microsoft-update infrastructure) so a bad list can never sever the tunnel or OS updates. Result:{ok, applied, doh_policy_applied, list_hash, applied_at}.webfilter_clear({}) is mutating: removes only kenny's marker block and the kenny-written DoH policy values, then flushes DNS. Result:{ok, removed_entries, doh_policy_cleared}.
On a non-Windows/dev build apply/clear return error.code = "unsupported" and status
reports {active: false, supported: false, ...}, keeping cargo test/cargo build green on
Linux CI. See ADR-0024.
Account governance tools¶
The account_* tools plus password_policy_set govern who may sign in to a host
(ADR-0042, extended to Linux by ADR-0043). They are the enforcement half of the
local_accounts telemetry section (below), which is also their inventory: there is no
account_list tool — read the section, and refresh it on demand with
telemetry_collect {sections:["local_accounts"]}.
All of them are mutating (refused with disabled under the kill switch). None of them
is OS-scoped: they are served on Windows and Linux alike, and per-account capability is
read from the inventory (see "Capability negation" below). On an OS with no
implementation (macOS today) the agent answers unsupported.
principal is the local account name — local_accounts.accounts[].name. On Windows
that is the SAM name, identical for a local and a Microsoft account; on Linux it is the
/etc/passwd login name. Both are unique per machine by construction, and both are exactly
what the platform's own tooling accepts. A Microsoft account on a workgroup PC is a SAM
entry with a machine-local SID and profile, so these operations are type-agnostic by
construction rather than by abstraction. Full SIDs and Microsoft-account email addresses
never go on the wire in either direction (ADR-0042).
account_set_enabled({principal, enabled}) — the suspend switch. Works identically for both account kinds; disabling a Microsoft-account-backed entry blocks sign-in on this PC and leaves the cloud account untouched. On Linux "disabled" means account expiry plus a locked password, not a locked password alone — a locked password still admits an SSH key.account_set_admin({principal, admin}) — add to or remove from the administrators group. On Windows that group is resolved by the well-known SIDS-1-5-32-544(locale-proof); on Linux it is the first ofsudo,wheel,adminthat exists in/etc/group, and removal strips membership in all of them.account_set_logon_rights({principal, deny}) —denyis the full desired set, a subset of["network", "remote_interactive"]. Passing[]clears both.SeDenyInteractiveLogonRightis deliberately not exposed: it can lock out the sole console user and kenny has no remote console to recover with. On Windows both rights are applied via LSA account rights (this works on Windows Home, where only thesecpol.mscsnap-in is missing, not the API). On Linuxremote_interactivedenies SSH sign-in through a kenny-ownedsshd_configdrop-in, andnetworkis reported as unsupported per account — Linux has no separate network-logon plane to deny.account_create({name, password, display_name?, admin?}) — creates a local account. On Windows this is the one genuinely asymmetric verb: a Microsoft account can be added to a PC only interactively. On Linux there is no asymmetry.account_delete({principal, remove_profile}) — removes the account entry; for a Microsoft-account-backed entry this unlinks the account from this PC and does not touch the cloud account.remove_profileadditionally deletes the user's profile/home directory.account_session_action({principal, action, warn_seconds?}) —action∈ {"lock","logoff"}. The agent resolves the principal to its interactive session(s) itself and reports which it acted on insessions; there is deliberately no telemetry section listing who is signed in — this returns proof of effect, not a presence log.warn_seconds(default 0) shows the user a message first. An account with no session succeeds with an emptysessionsarray. On Linux sessions come fromsystemd-logind, andlockneeds a graphical session — a headless host reports it unsupported.password_policy_set({min_length?, max_age_days?, lockout_threshold?}) — machine-wide, omitted keys are left unchanged. The result always carriesapplies_to: "local_only": on Windows because a Microsoft account's password is governed by Microsoft's cloud policy, on Linux because the policy is enforced by local PAM and does not reach directory-backed (LDAP/SSSD) identities. Consumers must surface that rather than imply full coverage. Where the host lacks a knob entirely, the section-levelpassword_policy.unsupportedsays so.
Capability negation¶
What a particular account on a particular host can do is published in
local_accounts.accounts[].unsupported: a map of capability verb → short reason token,
listing only what is not possible. An absent verb is supported. Reason tokens are stable
identifiers for consumers to localize, never display strings.
The verb vocabulary — capability names, not tool names, so one tool can be partly available:
| verb | gates |
|---|---|
set_enabled |
account_set_enabled |
set_admin |
account_set_admin |
deny_network |
the network member of account_set_logon_rights's deny set |
deny_remote_interactive |
the remote_interactive member of that set |
delete |
account_delete |
session_lock |
account_session_action with action: "lock" |
session_logoff |
account_session_action with action: "logoff" |
session_warn |
account_session_action's warn_seconds — the warning, not the action |
reset_password |
advisory only — kenny has no password-reset tool |
Splitting account_set_logon_rights and account_session_action into two verbs each is
deliberate: a tool can be partly available, and a consumer needs that granularity to
disable one checkbox and leave the other live.
Reason tokens, and where they come from:
| token | source | meaning |
|---|---|---|
password_in_cloud |
account kind | a Microsoft/Entra password is not on this machine |
kind_unknown |
account kind | the probe could not determine the account kind |
root_account |
Linux account | uid 0 — group membership neither grants nor revokes root |
admin_via_sudoers |
Linux account | admin rights come from /etc/sudoers.d, which kenny never writes |
nologin_shell |
Linux account | the account has no login shell, and kenny does not rewrite shells |
no_network_logon_concept |
Linux host | Linux has no per-account network-logon plane to deny |
no_sshd |
Linux host | no SSH daemon, so there is no remote sign-in to deny |
sshd_no_include |
Linux host | sshd_config has no Include line, and kenny will not add one |
no_logind |
Linux host | no systemd-logind, so sessions cannot be enumerated |
no_graphical_session |
Linux host | headless — a session can be ended but not locked |
no_admin_group |
Linux host | none of sudo/wheel/admin exists |
shadow_unreadable |
Linux host | the agent cannot read /etc/shadow (not running as root) |
no_user_notification_channel |
Linux host | no way to warn the signed-in user first |
The machine-wide password_policy carries the same idiom one level up in its own optional
unsupported map, with its own tokens no_pwquality and pam_faillock_not_enabled.
The agent enforces a self-protection set it will not let an operator override, refusing
with blocked: the last enabled local administrator cannot be disabled, demoted, deleted, or
given deny-logon rights; built-in accounts (Windows RID 500/501) cannot be deleted; and any
action whose verb the inventory reports as unsupported for that account is refused. The
last rule is what keeps the published capability map and the enforced one from drifting apart
— it is the same list, read at call time. This mirrors the webfilter_apply reserved-name
rule: a governance call must never be able to lock everyone out of the machine.
Server-only MCP tools (not forwarded to a single agent)¶
| tool | args | purpose |
|---|---|---|
list_agents |
{} |
known agents + online state + overall health |
select_agent |
{id} |
validate an agent id and report it as a default (advisory only — does not route forwarded calls, see ADR-0038) |
fleet_overview |
{} |
per-agent rolled-up health for the dashboard |
agent_health |
{id} |
per-section status/summary for one agent |
agent_snapshot |
{id, section?} |
latest stored snapshot (or one section) for an agent |
agent_availability |
{id, days?} |
when one host was reachable: availability %, outages, reboots over the last days (1–30, default 7) |
webfilter_get |
{id} |
one host's parental-controls config + custom list |
webfilter_set |
{id, ...} |
edit a host's config/toggles or add/remove a domain |
webfilter_push |
{id} |
build the effective block set and forward webfilter_apply/clear |
web_activity_query |
{id, hours?, flagged_only?} |
observed/flagged domains for one host |
reliability_suppression_list |
{agent_id?} |
reliability alarm suppression rules (fleet-wide + host) |
reliability_suppression_add |
{event_id, source?, agent_id?, note?} |
exclude a (source, event_id) pattern from severity scoring |
reliability_suppression_remove |
{rule_id} |
remove a suppression rule |
ticket_rule_list |
{agent_id?} |
auto-ticket rules (fleet-wide + host) |
ticket_rule_set |
{event_type, decision, section?, agent_id?, note?} |
add/replace a rule deciding whether an alert opens a ticket |
ticket_rule_remove |
{rule_id} |
remove an auto-ticket rule |
The webfilter_* server-only tools manage the per-host list and trigger a push; they wrap
the forwarded webfilter_apply/webfilter_clear capability tools (ADR-0024). webfilter_set
and webfilter_push are state-changing (they pass the operator confirm-gate, ADR-0009).
A host's configuration has two independent settings (ADR-0069): enforcement ∈ {off,
log_only, protect} — what the filter does (nothing / record and alarm on matches / also
block) — and history ∈ {violations, full} — whether the server keeps every observed
domain or only the matches. webfilter_push forwards webfilter_apply only under
protect, otherwise webfilter_clear.
The reliability_suppression_* server-only tools manage the suppression-rule table behind
the Reliability card (ADR-0041); _add/_remove are state-changing (ADR-0009). They forward
nothing to an agent — agent_id is an optional scope filter, not a routing target.
The ticket_rule_* server-only tools manage which alerts open a ticket automatically
(server-side policy); _set/_remove are state-changing (ADR-0009). Like the suppression tools, they
forward nothing to an agent — agent_id is an optional scope filter, not a routing target.
agent_availability is read-only and forwards nothing to an agent. It reads the server's own
presence record: each tunnel session (connect to disconnect or heartbeat timeout), the server's
own run times, and the reboot instants from the uptime section. Time the server itself was not
running is unknown, not offline. Before that record existed, the server reconstructs presence
from telemetry arrival times and marks those spans approx.
Telemetry sections¶
Each section payload must include status ∈ {ok, warn, crit} and a short
summary string. Raw fields are section-specific (see docs/fixtures/telemetry_*).
Mandatory: disk, peripherals, network, routing, processes, services,
defender, win_update.
Hardware health: disk_smart, battery, memory, thermals, hardware_errors,
gpu, fans (optional).
Security & crypto: firewall, encryption, av_thirdparty, defender_quarantine.
Update & stability: reboot_pending, os_support, reliability, app_updates.
Operations & daily: uptime, time_sync, printers, wifi_quality, autostart.
Eight of these sections report without grading, like reliability and the inventory
sections below: services, encryption, printers, time_sync, uptime,
hardware_errors, gpu and fans always carry status: "ok", and the server's health
rules are authoritative for them (ADR-0058). The
raw fields are unchanged; only the collector's own verdict is gone, so a server-side rule
can relax a section as well as tighten it.
Parental controls: web_activity, screen_time.
Security inventory: installed_software, browser_extensions, listening_ports,
scheduled_tasks, local_accounts.
Resilience: backup_status, net_quality.
The web_activity section reports the host names a PC has been reaching in a rolling
window (default 24 h), observed from the OS DNS client cache and per-user browser history
(host names only — never full URLs, page titles, or which user visited). It is bounded:
domains are deduplicated and capped (250, last_seen desc, truncated beyond), well inside
the telemetry frame cap. The agent always reports status: "ok" — it holds no list and does not
judge; the server matches observed domains against that host's per-host list and is
authoritative (see ADR-0024). The section payload the agent sends:
"web_activity": {
"status": "ok",
"summary": "42 domains observed (24h)",
"window_hours": 24,
"sources": ["dns_cache", "browser_history"],
"domains": [
{ "domain": "example.com", "first_seen": "2026-06-04T09:12:00Z",
"last_seen": "2026-06-04T17:40:00Z", "hits": 7, "sources": ["dns_cache", "browser_history"] }
],
"truncated": false,
"browser_profiles_read": 3,
"errors": []
}
On telemetry insert the server annotates the stored payload with a flagged array (the
matches against the host's list, with category and timestamps) that the web_activity health
rule consumes. That annotation is server-internal and not part of this wire contract — the
agent never sends flagged. What the server keeps of the rest depends on the host's
configuration (ADR-0069): with history violations it stores only the matches, and the
stored domains array is empty. Off Windows the section is the standard n/a on this
platform stub with empty sources/domains; switched off by policy.collect it is the same
stub with the summary not collected (server policy).
The DNS client cache also holds the names the hosts file resolves locally — Windows preloads
them — so the collector drops cache entries whose address is a sinkhole (0.0.0.0 or ::).
Without that, every name webfilter_apply blocks would be reported as reached.
The reliability section reports what is going wrong, not just how many errors there are:
a breakdown of the Error/Critical entries in the System + Application event logs over a rolling
window (default 7 days), grouped by Windows source + event id. Each group carries a real sample
message, its level, a total count, when it was last seen, and a per-day histogram. The section
payload the agent sends:
"reliability": {
"status": "ok",
"summary": "192 error/critical events in 7d",
"stability_index": 6.8,
"recent_crashes": 192,
"window_days": 7,
"events": [
{ "source": "Application Error", "event_id": 1000, "level": "error", "count": 84,
"sample": "Faulting application name: chrome.exe, version 126.0.0.0 ...",
"last_seen": "2026-07-01T20:14:33Z",
"by_day": { "2026-06-27": 10, "2026-06-28": 12 } }
],
"boot_sessions": ["2026-06-27T06:12:04Z", "2026-06-29T07:41:55Z"],
"truncated": false,
"truncated_count": 0
}
Field rules a consumer may rely on:
by_daykeys are UTC calendar dates, matchinglast_seen's timezone. Both are derived from the same event timestamps, so the number of distinct keys is a timezone-independent measure of how many days a pattern spans.last_seenis the timestamp of the group's newest member, andsampleis that same newest member's first message line (capped ~200 chars) — one representative of a potentially heterogeneous group, not a summary of it. A generic event id (Application Error/1000,Service Control Manager/7024) groups unrelated failures together, so the sample names whichever one happened last.- The group list is bounded, but not by count alone: every
level: "critical"group is kept first (up to 8), then the largest groups by count, then the most recently seen, up to 40 groups total.truncatedsays whether anything was dropped andtruncated_countsays how many groups — sorecent_crashescan be reconciled against the sum of the reported groups' counts. Ordering by count alone would drop a single bugcheck behind a chatty but harmless pattern; the reserved critical slots exist so the rare events that matter always arrive. boot_sessionslists the UTC instants at which the host booted inside the window (fromKernel-Boot/20andEventLog/6005, read in the same query and the same clock as the events themselves), newest last and capped at 20. It lets the server tell a pattern that recurred across several boots from one that fired once on the way down, and tell an unexpected shutdown from an orderly one. Empty when unavailable.
stability_index (Windows Reliability Index, 0–10, or null) and recent_crashes (the total
count of matching entries in the window) are retained. The agent always reports status: "ok"
— it reports what happened and does not grade it; the server's health rules are authoritative
for this section (like web_activity above). A status an agent does send is not folded into
the rule's verdict.
On the read path the server annotates each group with a user_impact
(none/degraded/crashed/data_at_risk) and a plain-language symptom, plus a friendly
category, a severity (benign/notable/serious/unknown) and a short
suspected_cause (via the connected LLM, persisted per pattern). Only user_impact drives
the health verdict — the rest is diagnostic context for the dashboard and the heatmaps. A
classification_state (classified/pending/unavailable) says whether that verdict is a
judgement, still in flight, or structurally unavailable, so a missing verdict is never read
as an all-clear. If the operator has suppressed this exact (source, event_id) pattern
(ADR-0041), the read path additionally stamps suppressed: true and a suppressed_by
descriptor, excluding the group from scoring while leaving its count untouched; unlike the LLM
annotation above, suppression needs no API key and so is stamped on every read path, including
agent_snapshot. The health rule derives each group's activity (days active, boot sessions
spanned, age of last_seen, whether it is still happening) from the by_day, last_seen and
boot_sessions fields above — that derived record appears only in health output, never on the
wire. These annotations are all server-internal and not part of this wire contract — the
agent never sends them (see ADR-0026, ADR-0041, ADR-0058). Off Windows the section is
the n/a on this platform stub with events: [] and boot_sessions: [].
The uptime section reports when the host last booted:
"uptime": {
"status": "ok", "summary": "up 3d 4h",
"uptime_secs": 273600,
"boot_time_unix": 1780322400
}
uptime_secs— seconds since boot at collection time.boot_time_unix— the boot instant as Unix seconds (UTC). It is derived from the current time minus the uptime, so successive pushes of one boot can differ by a second or two; the server treats boot instants within 120 s of each other as the same boot. The server records every distinct value as a reboot marker in its availability history.
The agent always reports status: "ok"; whether a long uptime is a finding is the server's
call (ADR-0058).
The os_support section reports the OS edition, its end-of-support posture and the host's
identity (ADR-0036):
"os_support": {
"status": "ok", "summary": "Windows 11 Pro",
"name": "Windows", "version": "11 (26200)", "build": "26200",
"eol": false, "eol_date": null, "arch": "x86_64", "channel": "stable",
"cpu": { "vendor": "GenuineIntel", "brand": "13th Gen Intel(R) Core(TM) i7-13700K",
"family": 6, "model": 183, "stepping": 1,
"microcode": "0x12b", "microcode_bios": "0x123" }
}
- A top-level field whose value is
nullmay be absent from the payload; the two are the same to a consumer. name,version— the OS name and version string;build— the Windows build number,nullelsewhere;eol/eol_date— whether the OS is past end-of-servicing and the (ISO-8601 UTC) date,nullwhen unknown;arch∈x86_64/aarch64(protocol 0.13);channel∈stable/dev(protocol 0.17).cpu(protocol 0.22) isnullwhen the CPU identity cannot be read, else an object:vendor(GenuineIntel,AuthenticAMD, …) andbrand(the brand string) as the CPU reports them;family,model,stepping— integers as the CPU reports them (the decimal values, e.g.6/183/1for Intel family 6 model 0xB7 stepping 1);microcode— the running microcode revision as a lowercase hex string ("0x12b"),nullwhen unreadable;microcode_bios— the revision the firmware loaded at boot when the OS reports it separately, elsenull.- Windows reads
HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0:ProcessorNameString,VendorIdentifierandIdentifier(Intel64 Family 6 Model 183 Stepping 1).Update Revisionis a REG_BINARY whose revision is the high dword on Intel and the low dword on AMD;microcode_bioscomes fromPrevious Update Revisionwhen present. - Linux reads
/proc/cpuinfo(vendor_id,model name,cpu family,model,stepping,microcode);microcode_biosisnull.
Whether a CPU model plus microcode revision is a finding is the server's call; the agent only reports the facts.
The disk_smart section reports one row per physical disk. On Windows the rows come from
Get-PhysicalDisk joined with Get-StorageReliabilityCounter, the drive's own SMART
predict-failure flag (root\wmi MSStorageDriver_FailurePredictStatus), the ATA
attribute table (MSStorageDriver_FailurePredictData) and, for NVMe disks, the NVMe
health log read from the raw device (log page 0x02). On Linux (protocol 0.22) the rows
come from the NVMe admin ioctl (health log 0x02, needs root) and an opportunistic bounded
smartctl --json for ATA disks, with identity from sysfs/udev. The section lists at most
16 disks; truncated says the host has more:
"disk_smart": {
"status": "ok", "summary": "SMART healthy",
"disks": [
{ "model": "WD_BLACK SN850X 2000GB", "health_status": "Healthy",
"predictive_failure": false, "wear": 2, "temperature_c": 41, "power_on_hours": 1520,
"read_errors_total": null, "read_errors_uncorrected": 0, "write_errors_uncorrected": 0,
"device_number": 2, "serial": "23012A800123", "bus_type": "NVMe", "media_type": "SSD",
"size_bytes": 2000398934016, "removable": false, "temperature_max_c": 74,
"smart_attributes": null,
"nvme": { "critical_warning": 0, "available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 2, "media_errors": 0, "unsafe_shutdowns": 14,
"error_log_entries": 0, "data_units_written": 18734512,
"power_on_hours": 1520, "temperature_c": 41 },
"nvme_error": null, "paused": false }
],
"truncated": false
}
Per row, in two groups. The first group is the original row and keeps its meaning:
health_status— the disk's ownHealthStatusstring (Healthy,Warning,Unhealthy,Unknown). Linux has no OS-level verdict: it reportssmartctl's overall verdict (Healthywhensmart_status.passedis true,Unhealthywhen false) andUnknownwhensmartctlis missing or gave none, and alwaysUnknownfor an NVMe disk.predictive_failure— the drive's own SMART predict-failure flag: on WindowsMSStorageDriver_FailurePredictStatus.PredictFailure, on Linuxsmartctl'ssmart_status.passed == false.nullwhen the flag is unavailable (no such WMI instance, nosmartctl, an NVMe disk on Linux) —nullis unknown, notfalse. Before 0.22 the field echoedhealth_status ≠ "Healthy".read_errors_total— every read error the drive counted, almost all of them corrected. Vendors scale it differently, and on some HDDs it is a large number on a healthy drive, so it never grades the section.read_errors_uncorrected/write_errors_uncorrected— errors the drive could not recover (on NVMe, the media and data-integrity error count). Non-zero means data was lost at least once. On Linux only NVMe disks fillread_errors_uncorrected(from the NVMemedia_errors); the other counters arenullthere.wear— percent of rated endurance used (0–100, SSDs; on Linux the NVMepercentage_usedclamped to 100);temperature_c— °C;power_on_hours— hours powered on.
The second group is new in 0.22 and additive:
device_number— integer ornull: the WindowsPhysicalDriveNnumber, the join key forhardware_errorsdisk events (details.disk_number). Alwaysnullon Linux.serial— string ornull: the drive's serial number, stable across reboots.bus_type— one ofNVMe,SATA,SAS,USB,RAID,SCSI,SD,Unknown.SDis an SD / MMC card reader. On Windows the agent maps the rawGet-PhysicalDiskBusType(theSTORAGE_BUS_TYPEnumbering): ATA, ATAPI and SATA areSATA; SCSI, iSCSI and Fibre Channel areSCSI; SD and MMC areSD; virtual, Storage Spaces and any other value areUnknown. On Linuxnvme*isNVMe, a device under a USB hostUSB,mmcblk*SD,sd*otherwiseSATA, and anything elseUnknown.media_type— one ofSSD,HDD,Unspecified.size_bytes— integer ornull;removable— boolean, true for removable media (everyUSBandSDdisk, and any disk Windows flags as removable media).temperature_max_c— number ornull: the highest temperature the drive has recorded, °C.smart_attributes— an object ornull: a map from the ATA attribute id as a decimal string to that attribute's raw value (integer), restricted to the ids"5"(reallocated sectors),"187"(reported uncorrectable),"188"(command timeout),"197"(current pending sectors),"198"(offline uncorrectable) and"199"(UDMA CRC errors). An id the drive does not report is absent from the map.nullwhen the disk is not ATA or the table is unreadable.nvme— an object ornull, decoded from the NVMe SMART / health log (page0x02):critical_warning(integer bitfield: bit 0 spare below threshold, bit 1 temperature, bit 2 reliability degraded, bit 3 read-only, bit 4 volatile-memory backup failed),available_spare,available_spare_thresholdandpercentage_used(integer percent;percentage_usedmay exceed 100),media_errors,unsafe_shutdowns,error_log_entries,data_units_writtenandpower_on_hours(integers) andtemperature_c(integer °C, ornullwhen the controller reports 0 K, meaning unset). The 128-bit NVMe counters saturate to the unsigned 64-bit maximum; values above 2^53 lose precision in JavaScript consumers.nvme_error— string ornull: whynvmeisnullon an NVMe disk (for example"unsupported by driver"behind a RAID/VMD driver or a USB bridge). A non-nullnvme_erroris never to be read as a healthy disk.paused— boolean: the raw-device NVMe health-log read (opening\\.\PhysicalDriveNon Windows, the admin ioctl on Linux) was skipped because anti-cheat coexistence is active (ADR-0035). Only that read is skipped:nvmeisnull(withnvme_errornull), whilepredictive_failure,smart_attributes,health_statusand the reliability counters are read as always, so a drive's own failure signals — including Windows'health_status, which reflects the drive's critical warnings — still reach the server while a protected game runs.
All counters are lifetime values, and each is null when the drive or its driver does not
report it — null is unknown, not zero. The reallocated-sector count appears only as SMART
attribute "5" in smart_attributes; Get-StorageReliabilityCounter does not expose it.
The section status is still the agent's own grade: any disk whose health_status is
neither Healthy nor Unknown or whose predictive_failure is
true → crit; else any disk with a non-zero uncorrected read or write count → warn; else
ok. The server's health rule is
authoritative for disk_smart: it judges the new fields (nvme, smart_attributes,
bus_type, removable, nvme_error) and its verdict replaces the agent's grade. It sets
aside USB and SD disks and any removable one, and reports an NVMe disk whose nvme is
null with a non-null nvme_error (and that is not paused) as a standing finding — an
unreadable health log is never read as a healthy disk. The agent's grade stands
only for payloads the rule defers on because they have the old row shape (no nvme and no
smart_attributes keys, as sent by an agent before 0.22). Where no physical disk can be
listed the section is { "disks": [] }.
The hardware_errors section reports which hardware-relevant events happened in a
rolling window (default 14 days): corrected and uncorrected machine-check and PCIe errors,
graphics-driver resets, storage retries, unexpected power loss and bugchecks, plus
aggregate application-crash diversity. It is the raw material for the server's "which
component is failing" judgement:
"hardware_errors": {
"status": "ok", "summary": "13 hardware-relevant events in 14d",
"window_days": 14, "effective_window_days": 14,
"oldest_event_utc": "2026-05-12T03:41:09Z",
"sources": ["event_log"],
"groups": [
{ "source": "Microsoft-Windows-WHEA-Logger", "event_id": 19, "level": "warning",
"count": 3, "last_seen": "2026-06-03T21:07:44Z",
"by_day": { "2026-05-29": 1, "2026-06-01": 1, "2026-06-03": 1 },
"sample": "A corrected hardware error has occurred.",
"details": { "error_source": { "Corrected Machine Check": 3 },
"error_type": { "Bus/Interconnect Error": 3 },
"processor_apic_id": { "6": 2, "14": 1 } } }
],
"truncated": false, "truncated_count": 0,
"app_crashes": { "total": 17, "distinct_apps": 5, "distinct_modules": 4,
"exception_codes": { "0xc0000005": 11, "0xc0000409": 4 },
"by_day": { "2026-06-03": 5, "2026-06-04": 12 } },
"edac": [],
"aer": [],
"errors": []
}
Field rules a consumer may rely on:
window_daysis the window the agent queried (14).effective_window_daysis how far back the queried log actually reaches —window_dayswhen the oldest record is older than the window, shorter when a wrapped System log has already dropped older records,nullwhen unknown — andoldest_event_utcis that oldest record's UTC timestamp (nullwhen unknown). They keep a log that wrapped after three days from reading as eleven quiet ones.sourceslists what the agent read:["event_log"]on Windows,["journal", "edac", "aer"]on Linux. A source it could not read is dropped from the list and named inerrors; anedacoraersysfs directory that does not exist (no EDAC driver, no PCI bus directory) is simply not a source — it is dropped without an error.groupsare keyed by(source, event_id)and have the same semantics asreliability's events:by_daykeys are UTC calendar dates,last_seenis the newest member's UTC timestamp (...Z),sampleis the newest member's first message line (at most 200 characters), andlevel∈critical/error/warning/information(event levels 1–4).countis the number of events in the group; thesummary's event count is the sum over all groups, including dropped ones.- The group list is capped at 24. The most recent and the largest groups are kept;
truncatedsays whether anything was dropped andtruncated_counthow many groups. detailsmaps a key to{ value: count }, each key holding at most 5 values. The counts cover only the newest ~10 events per group whose event XML was read (reading every event's XML would not fit the collection budget), so adetailscount may be lower than the group'scount. The values are strings, exactly as the event reports them. Documented keys on Windows:Microsoft-Windows-WHEA-Logger:error_source,error_type,processor_apic_id,pci_vendor_device("1022:1483") andpci_location;Microsoft-Windows-Kernel-Power41 andBugCheck/Microsoft-Windows-WER-SystemErrorReporting1001:bugcheck_code, a hex string such as"0x00000124"(Kernel-Power 41 stores it decimal; the agent converts);Microsoft-Windows-Kernel-Power41:power_button,"true"or"false"—"true"whenPowerButtonTimestampis non-zero, i.e. the user held the power button;- the storage providers (
disk,storahci,stornvme):disk_number, joinable todisk_smartrows'device_number, anddisk_bus_type— that disk'sbus_typein thedisk_smartvocabulary (USB,NVMe, …), orUnknownwhen the event names no disk the agent can resolve — so a rule judging this section alone can set aside retries on USB drives and card readers; nvlddmkm:xid;Display4101:driver. Other keys may appear; a consumer ignores keys it does not know.app_crashesaggregatesApplication Error1000 in the Application log instead of listing it as a group, so a chatty crash loop cannot crowd the hardware events out of the cap:totalevents,distinct_apps,distinct_modules(module basenames only, never paths, and never per user),exception_codes({ "0xc0000005": n }) andby_day. It isnull(or absent) on Linux and when the Application log cannot be read.- Linux groups come from the kernel journal.
sourceis the matcherkeyfrom the vector file below (mce,edac,nvrm_xid,amdgpu_ras,block_io,nvme,ata,pcie_aer) andevent_idis0;detailscarries the matcher's capture group where it has one:xidfornvrm_xid,deviceforblock_io.edacandaerare filled on Linux only, are empty lists on Windows and are capped at 32 entries each: - an
edacentry is{ "controller": "mc0", "ce_count": 3, "ue_count": 0 }— one per memory controller, from/sys/devices/system/edac/mc/mc*/{ce_count,ue_count}, in numeric controller order; the counts are the controller's lifetime corrected and uncorrected error counts (an unreadable counter file reads as0); - an
aerentry is{ "device": "0000:01:00.0", "correctable": 12, "nonfatal": 0, "fatal": 0 }— one per PCIe device, from theTOTAL_ERR_*lines ofaer_dev_{correctable,nonfatal,fatal}(the sum of the per-error lines when a file has no total), listed only if the device's total is non-zero, most errors first. - Errors versus emptiness. A probe that fails (a query that errors, a timeout, an
unreadable log) adds a string to
errors; a query that matches nothing is not an error. Off-platform or when nothing can be read, the section carries empty lists andstatus: "ok", never then/a on this platformstub.
Which events the agent queries is a closed set, the file
fixtures/vectors/hardware_event_query.json
(the agent's query set): window_days, windows (one entry per Windows provider — log,
provider, event_ids, max_level, where level 1 is critical, 2 error, 3 warning and 4
information, and an optional aggregate naming the section field the events fold into) and
linux_kernel_patterns (a matcher key and a regex over journal kernel messages). The
agent's constant and the server's attribution tables must both equal it, so an event the
server attributes is always an event the agent queries.
The agent reports facts and does not judge: component attribution, the meaning of a
bugcheck code and every threshold are server-side and not part of this contract (the
agent always reports status: "ok", like reliability, ADR-0058). A status an agent
does send is not folded into the rule's verdict.
The gpu section inventories the graphics adapters and reports their raw health facts:
"gpu": {
"status": "ok", "summary": "1 GPU(s)",
"gpus": [
{ "name": "NVIDIA GeForce RTX 4080", "vendor": "nvidia", "pci_id": "10de:2704",
"bus_id": "0000:01:00.0", "uuid": "GPU-4f1c2a6e-8d3b-7c59-1e20-a9b3c4d5e6f7",
"driver_version": "560.94", "sources": ["wmi", "nvidia-smi"],
"temperature_c": 47, "utilization_percent": 6, "power_draw_w": 38.5,
"power_limit_w": 320.0, "fan_target_percent": 30,
"pcie": { "gen_current": 1, "gen_max": 4, "width_current": 16, "width_max": 16 },
"throttle": { "hw_slowdown": false, "hw_thermal_slowdown": false,
"hw_power_brake_slowdown": false, "sw_thermal_slowdown": false },
"ecc": null, "ras": null }
],
"truncated": false, "errors": []
}
vendor∈nvidia,amd,intel,unknown.nameis always a string; every other per-GPU field exceptvendorandsourcesisnullwhen the source does not report it (a[N/A]fromnvidia-smiisnull).pci_idis"<vendor>:<device>"in lowercase hex,bus_idthe PCI address (0000:01:00.0),uuidthe vendor's device UUID (NVIDIAGPU-…),driver_versionthe driver's version string.sourceslists where the facts came from, from the open setwmi,nvidia-smi,sysfs.temperature_c,utilization_percent,power_draw_wandpower_limit_ware numbers.fan_target_percentis the driver's target fan speed, not a measured speed — thefanssection carries measured RPM.pcie—gen_current/gen_max/width_current/width_max, integers. The current link generation (and, on some cards, width) downshifts at idle by design, so a snapshot's current values only say something when the GPU is under load.throttle— the four clock-event reasons as booleans:hw_slowdown,hw_thermal_slowdown,hw_power_brake_slowdown(an external power-brake signal) andsw_thermal_slowdown;nullwhen the driver does not report them.ecc—nullon cards that report none of it, elseuncorrected_volatile(integer ornull),retired_pages_pending(boolean ornull) andremapped_rows(null, or an object withcorrectableanduncorrectableintegers andpendingandfailurebooleans, eachnullwhen the driver does not report it).ras— amdgpu on Linux only: a map from RAS block (gfx,umc,sdma, …) to its{ "ue": n, "ce": n }error counts,nullelsewhere — including an amdgpu device whoserasdirectory holds no counter files.- The list is capped at 8 GPUs (
truncated);errorsholds the strings of probes that failed. A host without a readable GPU reportsgpus: []andstatus: "ok".
The agent does not grade this section; thresholds are the server's call.
The fans section reports measured fan speeds, sampled in a short burst so the server
can tell a stable fan from a stalled or unstable one without the agent keeping state
(ADR-0007):
"fans": {
"status": "ok", "summary": "3 fans read",
"sources_tried": ["hwmon"],
"sample_interval_ms": 1000,
"fans": [
{ "key": "nct6798.fan1", "label": "CPU_FAN", "source": "hwmon",
"rpm_samples": [1180, 1176, 1182, 1179, 1181], "duty_percent": 45.0,
"mode": "pwm", "idle_or_absent": false }
],
"truncated": false, "errors": []
}
- One collection takes a burst of 5 samples about
sample_interval_ms(1000) apart within the single collector call; the agent keeps no state between collections.rpm_samplesholds up to 5 samples in order: a sample whose read failed is omitted, so the list can be shorter than 5, and a fan with no successful sample is not listed. - Only fans with a measured RPM are listed. A fan whose speed is only a commanded
target (an NVIDIA GPU fan via
nvidia-smi) is not. keyis stable per fan across pushes: the hwmon chip name plus the fan index (nct6798.fan2) or a LibreHardwareMonitor sensor identifier.labelis the board's own name for the fan (CPU_FAN) ornull.source∈hwmon,lhm.duty_percentis the commanded PWM duty (0–100) when the agent can read it, elsenull.mode∈pwm,dc,auto,manual,unknown.idle_or_absentistruewhen every sample is 0 RPM and no duty is readable, i.e. the channel is unused or nothing is attached, which is not evidence of a stall.- The list is capped at 16 fans (
truncated).sources_triednames every source the agent attempted (hwmonon Linux,lhm_wmion Windows) anderrorsthe probes that failed. - Windows reads only the WMI namespace of LibreHardwareMonitor or OpenHardwareMonitor
when one is running on the host; kenny ships no ring-0 sensor driver (ADR-0035). On most
Windows hosts nothing is running, so
fansis[]withsources_tried: ["lhm_wmi"]— an empty list is the normal case, not a fault.
The agent does not grade this section; stall and drift judgements are the server's.
Security-inventory, resilience, and parental-awareness sections (v0.10)¶
Added at v0.10 (see ADR-0028, ADR-0029). All are additive; off Windows each is the
standard n/a on this platform stub with empty lists. Inventory lists are deduplicated,
sorted, and capped (with a truncated flag) so a section can never blow the telemetry
frame cap (unsolicited pushes are held to a tighter byte cap than correlated tool
responses; see the tunnel's inbound limits). The agent reports status: "ok" for pure inventory sections — judgment (health
rules and cross-snapshot diffing) is server-side.
installed_software— machine-wide program inventory from the registry Uninstall keys (HKLM 64+32-bit; notWin32_Product, which triggers MSI reconfiguration, and notwinget list, which is too slow for the probe budget). System components are filtered out; per-user (HKCU) installs are not visible to the session-0 service and are a documented blind spot. Cap 300.
"installed_software": {
"status": "ok", "summary": "142 programs installed",
"apps": [ { "name": "7-Zip 24.08 (x64)", "version": "24.08", "publisher": "Igor Pavlov", "install_date": "2026-03-11" } ],
"count": 142, "truncated": false
}
browser_extensions— extensions installed in Chromium-family browsers (Chrome/Edge manifest dirs) and Firefox (extensions.json), read from the same per-user profile locations asweb_activity. Privacy: deduplicated across users and profiles by(browser, id)— no per-user attribution on the wire. Cap 200.
"browser_extensions": {
"status": "ok", "summary": "9 extensions across 2 browsers",
"extensions": [ { "browser": "chrome", "id": "cjpalhdlnbpafiamejdnhcphjbkeiagm", "name": "uBlock Origin", "version": "1.58.0" } ],
"count": 9, "truncated": false, "profiles_read": 3, "errors": []
}
listening_ports— TCP listeners and UDP endpoints joined with the owning process image name, deduplicated by(proto, port, process), wildcard binds first. Cap 200.
"listening_ports": {
"status": "ok", "summary": "12 listening ports",
"ports": [ { "proto": "tcp", "port": 445, "address": "0.0.0.0", "pid": 4, "process": "System" } ],
"count": 12, "truncated": false
}
scheduled_tasks— non-Microsoft scheduled tasks (TaskPathnot under\Microsoft\), i.e. the persistence surface an operator actually reviews;total_countreports the full count for context. Cap 200.
"scheduled_tasks": {
"status": "ok", "summary": "4 non-Microsoft tasks (312 total)",
"tasks": [ { "path": "\\", "name": "OneDrive Update", "state": "Ready", "action": "%LocalAppData%\\OneDrive\\Update\\OneDriveSetup.exe", "run_as": "kid-pc\\kid", "last_result": 0, "next_run": "2026-06-05T03:00:00Z" } ],
"count": 4, "total_count": 312, "truncated": false
}
local_accounts— local users plus administrators-group membership. On Windows the group is resolved by SID (S-1-5-32-544, locale-proof) and built-ins are marked via the well-known RID (builtin_admin-500,builtin_guest-501); full SIDs never go on the wire (minimum identifying tokens, ADR-0024 stance). On Linux the source is/etc/passwd+/etc/group+/etc/shadow(+ a read-only scan of/etc/sudoers.d):is_adminis uid 0 or membership insudo/wheel/admin.builtin_adminis set for root, which is factually the built-in, undeletable administrator — so the same guard rule that protects Windows RID 500 protects it.builtin_guestis alwaysfalseon Linux; it has no analogue. Accounts with uid 1–999 andnobodyare not reported at all. Consumers must readbuiltin_admintogether with the agent's OS: an enabled built-in administrator is a finding on Windows (RID 500 ships disabled) and is simply how Linux works.password_requiredmirrors theUF_PASSWD_NOTREQDflag on Windows and an empty/etc/shadowpassword field on Linux — it means a blank password is permitted, not the account has no password.password_last_set(nullable RFC3339 UTC) disambiguates: it is set the moment a password is ever chosen, andnullonly for a genuinely password-less account. Health rules must consult both, notpassword_requiredalone.
Since v0.15 this section is also the inventory for the account_* governance tools
(ADR-0042), and each account carries four more fields:
kind∈local|microsoft|entra|unknown, fromPrincipalSourceon Windows. A Microsoft account is a SAM entry like any other — the section name still fits, andnameis the governance key for every kind. Every Linux account islocal.display—FullName(Windows) or the first GECOS field (Linux) if the account has one, elsename. Microsoft-account email addresses never go on the wire (ADR-0042, same rationale as the SID rule). Residual imprecision, stated rather than hidden: with noFullNamethe fallback is the SAM name, which Windows derives from the first five characters of the address.deny_logon— the sign-in restrictions currently in force, a subset of["network", "remote_interactive"]— LSA account rights on Windows, an sshdDenyUsersdrop-in on Linux (where onlyremote_interactiveis settable).SeDenyInteractiveLogonRightis not reported because it is not settable through kenny.unsupported— the per-account capability negation map; the verb and reason vocabulary is defined under "Capability negation" in the tool catalog above.
The section-level password_policy is machine-wide and carries its own
applies_to: "local_only" — Microsoft accounts follow Microsoft's cloud password policy,
and on Linux the policy is local PAM and does not reach directory-backed identities.
Any field may be null when the probe could not read it; an optional unsupported map
names the fields this host cannot set at all.
"local_accounts": {
"status": "ok", "summary": "3 accounts, 1 admin",
"accounts": [ { "name": "kid", "display": "Kid", "kind": "microsoft", "enabled": true, "is_admin": false, "password_required": true, "password_last_set": "2026-02-20T18:30:00Z", "last_logon": "2026-06-04T15:02:00Z", "builtin_admin": false, "builtin_guest": false, "deny_logon": ["remote_interactive"], "unsupported": { "reset_password": "password_in_cloud" } } ],
"admins": ["papa"], "count": 3,
"password_policy": { "applies_to": "local_only", "min_length": 8, "max_age_days": 0, "lockout_threshold": 10 }
}
logon_failures— failed sign-in attempts aggregated per account over a 24 h window. The source is Windows Security event 4625, or on Linux the sshd/PAM authentication failures in the journal (falling back to/var/log/auth.log). Reported per named account because an authentication attempt belongs to the identity plane that ADR-0042 governs, not to the behaviour plane that stays aggregated —screen_timeandweb_activityare unaffected by that decision. Attempts against names with no matching local account collapse intounmatched_count(a mistyped or probed username is not an account). No source addresses:types(⊆interactive|network|remote) already separates "someone tried a password at the console" from "something is hammering RDP", which is what the distinction is for. On Linuxnetworkdoes not occur — SSH isremote, console/display-manager attempts areinteractive, and there is no third plane to report.accountsis capped at 50 entries, sorted by descending count.
"logon_failures": {
"status": "ok", "summary": "3 failed logons in 24h",
"window_hours": 24,
"accounts": [ { "name": "papa", "count": 3, "types": ["interactive"] } ],
"unmatched_count": 0, "count": 3, "truncated": false
}
backup_status— evidence that any backup mechanism is alive: System Restore (enabled + restore-point count/latest), the File History service state (per-user File History configuration is unreadable from session 0, soconfiguredmay benull), and OneDrive presence/running. All sub-objects are best-effort and nullable.
"backup_status": {
"status": "ok", "summary": "restore point 2d ago; OneDrive running",
"restore_points": { "enabled": true, "count": 5, "latest": "2026-06-02T11:30:00Z" },
"file_history": { "service_state": "stopped", "configured": null },
"onedrive": { "installed": true, "running": true }
}
net_quality— a stateless probe of link quality at collection time: a handful of ICMP echoes to the default gateway and to a reference host (default1.1.1.1, agent-side overrideKENNY_NET_QUALITY_REF_HOST).latency_msis the median and isnullat 100 % loss.
"net_quality": {
"status": "ok", "summary": "gateway 2ms, internet 14ms",
"gateway": { "host": "192.168.1.1", "latency_ms": 2.0, "loss_percent": 0 },
"reference": { "host": "1.1.1.1", "latency_ms": 14.0, "loss_percent": 0 },
"samples": 5, "errors": []
}
screen_time— aggregated interactive minutes per calendar day for the whole machine over the last 7 days, derived from logon/logoff (and, where readable, lock/unlock) events. Privacy (ADR-0029): no usernames, no per-user split, no app names, no timestamps finer than the day bucket; each day is clamped to [0, 1440]. The agent recomputes the window on every push (stateless); the server's daily history provides longer trends. The agent always reportsstatus: "ok"— kenny reports, parents judge.
"screen_time": {
"status": "ok", "summary": "3.4h today, 24h over 7 days",
"window_days": 7,
"days": [ { "date": "2026-06-04", "active_minutes": 204 } ],
"source": "eventlog", "errors": []
}
Health thresholds (e.g. disk used > 80% ⇒ warn and ≥ 95% ⇒ crit; Defender
real-time protection off ⇒ crit; Defender scan older than 14 days ⇒ warn) are
evaluated server-side in kenny-server/kenny_server/health_rules.py. The agent
SHOULD set a reasonable status per section, but the server's rules are authoritative
for fleet aggregation. These thresholds are illustrative of the data-driven rules in
health_rules.py, which is the source of truth for exact boundaries.
Versioning¶
PROTOCOL_VERSION = "0.22". Both implementations expose this constant; from v0.8 the
agent puts it on the wire in register.protocol to select the mutual-auth handshake
(compare versions numerically per component, not lexically — "0.10" is newer than
"0.9"). Bump on any breaking change to a frame or tool schema.
-
0.22— predictive hardware-failure signals, additive except for one meaning change. New sectionshardware_errors,gpuandfans, which report raw facts withstatus: "ok"and no agent grading.hardware_errorsqueries a closed set of events (docs/fixtures/vectors/hardware_event_query.json).os_supportgainscpu. Thedisk_smartrows gaindevice_number,serial,bus_type,media_type,size_bytes,removable,temperature_max_c,smart_attributes,nvme,nvme_errorandpaused, the section gainstruncated, anddisk_smartnow also reports on Linux (it was then/astub). Semantic change:disk_smart.predictive_failureis now the drive's own SMART predict-failure flag (nullwhen unavailable) instead ofhealth_status ≠ "Healthy". The server'sdisk_smartrule is authoritative where it applies; an agent before 0.22 keeps working because the rule defers on its payloads. -
0.21— thedisk_smartrows dropreallocated_sectors, which carriedGet-StorageReliabilityCounter'sReadErrorsTotal(the mostly-corrected read-error count), not SMART attribute 5. The rows now carry that value asread_errors_total, plusread_errors_uncorrected,write_errors_uncorrectedandpower_on_hours. A non-zero uncorrected count grades the sectionwarn. No server code read the old field; a server predating this version stores the new fields as opaque section data. -
0.20— addedcollectto thepolicyframe (ADR-0069): a per-host map of telemetry section → whether the agent collects it, honoured today forweb_activity. A section switched off is reported as a stub with the summarynot collected (server policy). Theweb_activitycollector also stops reporting DNS-cache entries that resolve to a sinkhole address (0.0.0.0/::), which are the hosts file's own entries and not visits. Additive and optional: a frame withoutcollectis unchanged from v0.19, and an agent predating this version ignores the field and keeps collecting — the server discards what it did not ask for on insert, so for those agents the server is the only enforcement point. 0.19— thereliabilitysection gainsboot_sessions(the UTC boot instants inside the window, read fromKernel-Boot/20andEventLog/6005in the same query and the same clock as the events) andtruncated_count(how many groups the cap dropped, sorecent_crashescan be reconciled against the reported groups). Group selection now reserves slots forlevel: "critical"groups and for the most recently seen before filling by count, up to 40 groups: ordering by count alone dropped a single bugcheck behind a chatty harmless provider, so the rarest and most consequential events never reached the server at all.by_daykeys are now UTC calendar dates, matchinglast_seen; they were local dates while the server parsed them as UTC, which made the number of distinct days a pattern spans depend on the host's timezone. That is the one non-additive change here: an agent predating this version still sends local-date keys, and the server reads them exactly as it did before — no worse, and correct once the agent updates. The rest is additive, and a server predating this version ignores both new fields.0.18— addedshellto thepolicyframe (ADR-0064): the fleet's shell execution mode (unrestricted/allowlist/off) plus, underallowlist, the anchored allow rules that say whatpowershell_execandshell_execmay run. Deny rules answer "what must never run"; the mode answers "what may run at all", so the default verdict for an arbitrary command is no longer "run it". Additive and optional: a frame withoutshellis unchanged from v0.17, and an agent predating this version ignores the field and keeps runningunrestricted— for those agents the server's mirror is the only enforcement point. No tool schema changed; the refusal reuses the existingblockederror code.0.17— addedchannel(∈stable/dev) toregister.meta, mirrored into theos_supporttelemetry section on every push — the same one-time-plus-periodic reporting patternarchestablished at v0.13 (ADR-0036), reused here for the second release channel (ADR-0048).channelis the built-in value baked in at compile time (KENNY_AGENT_CHANNEL, defaultstable); it says what stream produced this binary, not what an operator wants it to become. Additive: no frame/tool-schema changes,RegisterMeta/Sectionalready accept new fields. Legacy agents that omitchannelon either wire are treated asstable.0.16— account governance is no longer Windows-only (ADR-0043). The sevenaccount_*/password_policy_settools lose their OS scope: the server forwards them to Linux agents too, and the agent serves them from/etc/passwd//etc/shadow//etc/group,usermod/useradd/userdel, an sshdDenyUsersdrop-in,loginctl, and pwquality/login.defs/faillock.logon_failuresgained a Linux arm (sshd/PAM failures from the journal) and is no longer skipped for non-Windows agents. No frame or field shape changes: every Linux asymmetry is published in the existing per-accountunsupportedmap, whose verb and reason vocabulary is now written down explicitly (see "Capability negation"), and the agent's self-protection guard refuses any action whose verb that map names — so the published capability set and the enforced one cannot drift. Additive extras: an optionalpassword_policy.unsupportedmap, andpassword_required/password_last_setnow carrying real values on Linux. Existing Windows payloads and fixtures are byte-identical.0.15— added account governance across local and Microsoft accounts (ADR-0042): seven tools (account_set_enabled,account_set_admin,account_set_logon_rights,account_create,account_delete,account_session_action,password_policy_set), four new per-account fields onlocal_accounts(kind,display,deny_logon,unsupported) plus a section-levelpassword_policy, and a newlogon_failurestelemetry section. Additive throughout — no frame changes, and existinglocal_accountsconsumers keep working since every addition is a new key. The governance key is the SAM account name, which both account kinds share; full SIDs and Microsoft-account email addresses stay off the wire.screen_timeandweb_activityare deliberately unchanged: identity became nameable, behaviour did not (ADR-0024, ADR-0029 hold).0.14— added theshell_exectool, a POSIX mirror ofpowershell_execfor Linux/macOS agents ({command, timeout_s}→{stdout, stderr, exit_code}, run viash -c).powershell_execnow returnsunsupportedon non-Windows agents instead of falling back tosh— the two tools are now a clean OS-scoped pair, and the server refuses to forward either to the wrong agent OS before it ever reaches the agent (see "OS-scoped tools" above). The shared deny-rule catalog gained aposixapplies_togroup with POSIX-destructive rules (rm -rf /,mkfs,dd/shred/wipefsdevice writes, fork bombs, recursivechmod/chownon/) alongside POSIX self-protection rules (systemctl stop/disable kenny-agent,kill/pkill kenny-agent), mirroring the existing PowerShell/self-protection groups (ADR-0019/0020). Breaking change forpowershell_exec's off-Windows behavior (dev/CI scripts relying on the oldshfallback must switch toshell_exec); additive new tool otherwise. See ADR-0031 (Linux agent support), which flagged this as future work.0.13— addedarchto theos_supporttelemetry section, mirroringregister.meta.arch. The server merges a reported value into the agent's storedarchon every telemetry push, giving it a periodic, self-refreshing signal in addition to the one-time value fromregister— so a long-lived connection stays correct even if the initialregister.meta.archwere ever missing or stale. Additive: no frame/tool-schema changes,Sectionalready accepts arbitrary fields. See ADR-0036. This does not retroactively fix an already-deployed pre-0.11 agent that never reportsarchon either channel — only updating/reinstalling to an arch-reporting build does.0.12— added thepausederror code for anti-cheat coexistence: while a protected game is running on the endpoint, the agent voluntarily suspends its most anti-cheat-visible tools (todayscreen_capture) and relaxes the process/port telemetry sections, returningpausedinstead of acting. Additive to the error-code set, no frame or tool-schema changes. See ADR-0035.0.11— addedregister.meta.arch(∈x86_64/aarch64) so server-triggeredagent_updateselects the binary matching the agent's CPU, fixing aarch64 Linux agents being bricked by a mis-routed x86_64 push (#139). Additive and backward compatible: legacy agents that omitarchstill register and default tox86_64. The Linux updater also now verifies the downloaded binary's ELFe_machinematches the host before the atomic swap, so a mis-routed binary is rejected instead of overwriting the working exe.0.10— added theinstalled_software,browser_extensions,listening_ports,scheduled_tasks,local_accounts,backup_status,net_quality, andscreen_timetelemetry sections (security inventory, resilience, parental awareness); additive sections only, no frame or tool changes. See ADR-0028, ADR-0029.- Refinement:
local_accountsaccounts gained a nullablepassword_last_setfield so health rules can tell a genuinely password-less admin from one that merely has theUF_PASSWD_NOTREQDflag set (blank password permitted but a real password present). Additive and backward compatible — no version bump. 0.9— added thewebfilter_status,webfilter_apply, andwebfilter_cleartools and theweb_activitytelemetry section for parental-controls observability and on-demand web filtering; additive tools + section, no frame changes. See ADR-0024.0.8— mutual agent⇄server authentication via per-agent Ed25519 signatures: added thechallenge(server → agent) andauth(agent → server) frames and theregister.protocol/register.client_noncefields;register.tokenbecomes optional (legacy, migration-window only). Breaking handshake change. See ADR-0022.0.7— added theremotehelp_status,remotehelp_start, andremotehelp_stoptools (orchestrate Windows Quick Assist as a remote-help concierge); additive tools, no frame changes. See ADR-0021.0.6— added thepolicyframe (server → agent) delivering the operator's append-only extra deny rules for the safety guard; additive frame, no tool changes. See ADR-0020.0.5— added theblockederror code for the agent's deterministic, always-on safety guard; additive to the error-code set, no frame or tool-schema changes. See ADR-0019.0.4— added thelogframe (agent → server) for forwarded structured log events; additive frame, no tool changes. See ADR-0017.0.3— renamed every capability tool from dotted (powershell.exec) to underscore (powershell_exec) identifiers so names are valid Anthropic tool names (^[a-zA-Z0-9_-]{1,128}$); breaking tool-schema change, no frame changes.0.2— added theagent_updatetool (server-triggered self-update); no frame changes.0.1— initial contract.