implementation: complete
Write Path and Audit Contracts
Contracts for everything that mutates state beyond the read-only fleet view: ssh-config editing, SSH key management, the audit log they report into, and the stale-host purge. Everything here is live and verified against the shipped binary (2026-09).
Config editing
~/.ssh/config stays the source of truth; the app edits it in place under
strict preservation rules.
- Stanza splicing, not regeneration —
sw-core'sedit_configis a pureString → Stringtransform that replaces only the lines of the targetHoststanza. Comments between blocks, indentation, directive casing, unknown directives (ProxyJump, …) and the raw~inIdentityFilepaths survive byte-identical. Regenerating the file was rejected precisely because the parser is lossy in the reverse direction. - Diff preview → confirm → apply — every write endpoint takes
dry_run; a dry run returns the unified diff without touching the file. The UI renders the same diff before its apply button. - Optimistic concurrency — updates may carry
expected_current(the stanza as the caller last saw it). If the stanza changed underneath, the apply answers 409 instead of overwriting. This is stanza-scoped on purpose: an unrelated edit elsewhere in the file does not block yours. - Cross-process locking — the read → edit → backup → write cycle runs
under an exclusive
std::fs::File::lockon<config>.sw.lock(0600), so the HTTP server and a concurrentsw mcpprocess cannot interleave their cycles. In-process, routes additionally serialize on a tokio mutex in the API state. Locking is fail-closed: no lock, no write. - Atomic write, rolling backup — the new text lands via tmp file
(0600) + rename; the previous content is kept as the single rolling
~/.ssh/config.sw.bak(overwritten each apply, never multiplied). The audit log's unified diffs are the history; the backup is only the one-step undo. - Rejections — multi-pattern
Hostlines, aliases containing wildcards or whitespace, duplicate adds, updates/deletes of unknown stanzas, and CRLF files are refused outright.Include/Matchpresent in the file produce warnings, not failures. - EOF normalization — add always terminates the file with exactly one trailing newline; deleting the last stanza also removes the separator blank line ahead of it, so an add → delete round trip is byte-identical (regression-tested).
Endpoint: POST /api/v1/ssh-config/hosts with
{dry_run, op: add|update|delete, stanza?, expected_current?, alias?}.
MCP: sw_edit_ssh_config (same payload, dry_run defaults to true).
Key management
- Scan —
~/.sshand~/CERT(configurable[keys] directories), recursive to depth 3. A file is a key when it has a.pubsibling OR the config references it as anIdentityFile(path-expanded compare — this is what picks up cloud.pemfiles)..pubfiles themselves,authorized_keys,known_hosts*andconfig*are excluded. Metadata only: type/bits/fingerprint (viassh-keygen -lf), size, mtime, referencing hosts. Private key material never leaves disk. - Generate — name validated (
[A-Za-z0-9._-], no leading dot, no..), refuses to overwrite; spawnsssh-keygen -t … -N "". - Delete — usage analysis first; if the key is referenced by any
host config, the call returns 409 listing them unless
force=true. Removes private +.pub. - Deploy — validates the
.pubcontent (single line,ssh-/ecdsa-/sk-prefix, no quotes/newlines — injection guard), then one remote command through the standard executor that is idempotent by marker:SW_ALREADYwhen the key is already inauthorized_keys,SW_ADDEDwhen appended. Recorded inexec_logand the audit log.
Endpoints under /api/v1/keys (GET, POST, DELETE ?force=,
/{name}/usage, /{name}/deploy). MCP: sw_list_keys,
sw_generate_key, sw_get_key_usage, sw_delete_key, sw_deploy_key.
Audit log
Append-only audit_log table (migration 0003): action, target,
detail (unified diff or summary, 8 KB cap), source (api | mcp),
created_at (RFC3339 UTC). Actions in use: config_add,
config_update, config_delete, key_generate, key_delete,
key_deploy, host_purge.
House rules:
- No-ops are not audited — a write that changes nothing (e.g. a purge with zero stale hosts) writes no row and emits no event. The audit log records what changed, not what was attempted.
- Source is injected at the seam — services carry a
with_source()builder defaulting toapi; the MCP handlers construct theirs withmcp. A route cannot accidentally log as the wrong caller.
Readers: GET /api/v1/audit?limit= (default 50, max 500), the dashboard
/audit page, and sw_list_audit over MCP.
Commands are a separate trail. exec_log records every run any surface
made — the dashboard's exec panel, an MCP sw_ssh_exec, and each host of
an assistant fan-out — with its exit code, duration and source. It is read
by GET /api/v1/exec-history?limit=&alias= (fleet-wide unless an alias
narrows it), the /exec page, and sw_list_exec_history. A missing exit
code means the run never completed (timeout, or ssh died), which is a
different fact from exiting non-zero.
Stale-host purge
Hosts that vanished from ~/.ssh/config are flagged
missing_from_config = 1 by sync ("stale") but kept — history is worth
more than disk. Purging is a deliberate second decision:
- Stale only —
DELETE /api/v1/hosts/{alias}hard-deletes a host row iff it is stale; an active host answers 409 pointing at the config delete path (remove the stanza, re-sync, then the row follows). - Cascade — the four child tables (status checks, metric samples,
hourly aggregates, exec log) reference
hosts(alias)withON DELETE CASCADE, and both store connections enable theforeign_keyspragma. OneDELETEcleans everything; there is no hand-rolled child cleanup to drift out of sync. - Bulk —
POST /api/v1/hosts/purge-stalepurges every stale host in one audited call (target: "*", detail lists the aliases). - Events — purges publish a
hosts_purgedFleetEvent so live dashboards refresh. - UI — the dashboard shows a stale banner (two-step confirm) when
stale hosts exist; a stale host's detail page replaces the config-delete
danger zone (which would 404 — the stanza is gone) with a
"purge from store" zone. MCP mirrors both as
sw_delete_host/sw_purge_stale_hosts,dry_rundefaulting to true.
MCP tool surface (22 tools)
- Read —
sw_list_hosts,sw_get_host,sw_get_status,sw_get_status_history,sw_get_metrics,sw_list_exec_history,sw_list_keys,sw_get_key_usage,sw_list_audit,sw_list_alerts. - Act on a host —
sw_ssh_exec,sw_check_host(probe now, recorded like a scheduled probe),sw_patch_host(tags, group, notes, enabled — columns sync never writes, so an agent's tidying survives the next import). - Diagnose this machine —
sw_check_network. An agent should ask it before concluding a fleet is down: while the verdict is notonline, host-down alerting is frozen by design and every host looks unreachable. See 004-alerting.md. - Change configuration (
dry_rundefaults true where a mistake costs something) —sw_sync_ssh_config,sw_edit_ssh_config,sw_generate_key,sw_delete_key,sw_deploy_key,sw_delete_host,sw_purge_stale_hosts,sw_set_alert_config.
Every applied change writes an audit row with source='mcp', which is how
an operator finds out that an agent moved something — most importantly a
threshold, where the failure mode is silence rather than an error.
The assistant is deliberately absent: an agent already has the tools the assistant would call, and proxying one model through another buys nothing.
Errors are domain-mapped: parse/validation → -32602, not-found and
conflict → tool errors with recovery hints, the rest → internal. The
server is hand-rolled JSON-RPC 2.0 over stdio (no rmcp), sharing the same
SQLite file and services as the HTTP API — WAL mode is what makes the
two-process access safe.