Skip to content

Hatchling 0.4.0

Choose a tag to compare

@undergroundrap undergroundrap released this 18 Oct 17:46
22a5fd0

Added

  • hatchling-broker clean [--dry-run]: removes a dead broker's leftovers, a cell's staging directory ($TMPDIR/hatchling-staging-<pid>-…, whose broker is gone) and a run's directory (<scheduler dir>.runs/run-…), as each start now does too, and logs each. --dry-run lists them and removes nothing. It refuses while a broker serves the socket. It never touches your data: no symlink is followed, no mount is entered, and every configured state directory is kept whole, wherever it is. What Hatchling keeps on disk says which files are your data and which are disposable, and how to back up, restore and uninstall (WO-012).

  • internal:host:port in HATCHLING_EGRESS_ALLOWLIST (and a coding CLI's vendor API): a destination the operator means to be on their own network, allowed to resolve to a private or loopback address; otherwise an ordinary credentialed HTTPS destination (WO-023).

  • The Machine view, for whoever runs the machine: hatchling-host, given HATCHLING_OPERATOR_SOCKET, also serves machine.html and carries its channel to the broker's operator socket, printing a second address with the operator token. It shows the broker's version and uptime, the cells running, the connections open and the recent egress decisions, refreshed every two seconds, and metadata only. CI opens it in Chromium beside the app (WO-022, part 6).

  • HATCHLING_OPERATOR_SOCKET: a socket for the people who run the machine, apart from its users. It speaks its own frames, so a user's socket can't answer an operator's query and the operator's can't take a user's action; it has its own token in <socket>.token; and its one query so far, broker, answers with the broker's version and uptime (WO-022, part 1).

  • The operator's cells query: every cell running, oldest first, with its id, kind (agent, run, subagent, skill or harness), start and limits, and never its task, input or output (WO-022, part 2).

  • The operator's egress query: the broker's last 200 egress decisions, with when, which cell, the destination and a verdict from a closed set, never a request's path, headers or body, or its answer (WO-022, part 4).

  • The operator's sessions query: every API and UI connection open, oldest first, with its id, socket and when it was admitted, never what it said (WO-022, part 3).

  • The README's "Using the app": build the UI, start hatchling-host, open the address it prints, and chat with the agent. CI runs it as written and checks the agent's answer on the page in Chromium (WO-021, closed).

  • Chat: the broker answers each message sent on the local channel, through the API or the ingress, with an agent run in a fresh cell, given the message as its task, one at a time. Its grants are the API caller's, and it runs only when they include channel-send:local (WO-021, part 4).

  • HATCHLING_UI_SOCKET: a second local API socket, for the frontend host to carry a browser's channel to. It serves the same protocol as HATCHLING_API_SOCKET, and every hello on it must also carry the UI token, which the broker mints at each start into <socket>.token, readable by its owner alone (WO-021, part 1).

  • The frontend starts live when hatchling-host served it: the address the host prints carries the UI token as #hatchling-ui=<token>, which the app takes and clears from the address before its own routing reads it, then speaks to the broker over a WebSocket to the host's /ui, its hello carrying the token. Opened any other way, it runs its scripted demonstration as before. The app's main chat is the broker's local channel on the wire (WO-021, part 3).

  • The local API reports the agent's status on the stream: working once a message is queued for it, with the message's first line as the detail, and idle when its reply arrives (WO-021, part 4).

  • hatchling-host, the frontend host: it serves the built UI on 127.0.0.1 (HATCHLING_HOST_PORT, default 7340; HATCHLING_HOST_UI names the build) and carries the browser's WebSocket to the broker's UI socket (HATCHLING_UI_SOCKET), frame for frame. It prints the address to open, with the UI token in the URL's fragment. Its WebSocket frames are read strictly: whole, masked, bounded by the broker's own frame limit, text, close, ping and pong only. Its files are named only by plain segments and served only when regular, never through a symlink. A new crate (WO-021, part 2).

  • Testing code that deletes: how a mutant of the new removal code deleted a test machine's WSL home during WO-012, and the rules since: two independent guards per escape, tests that delete only what they made, a bubblewrap sandbox for the tests and mutation runs of such code, and canary files (WO-012).

Changed

  • One broker per socket, and its helpers end with it. A serving broker holds a lock beside its socket (<socket>.lock) for its life; a second broker on the same socket refuses to start. A run's or a skill's helper process now watches the broker that started it, by pidfd, and ends, with its cell, the moment that broker does: a broker killed with SIGKILL used to leave its helpers and their cells running until they ended on their own (WO-012).
  • The redaction audit log confirms no guesses. A redaction is recorded by tags, HMAC-SHA-256 under a random key the broker makes at its first start with the gate on and seals under the master key in the redaction store (raw/audit-tag-key), instead of unkeyed SHA-256 hashes, which let anyone holding the log test a guess at a short secret or phrase. redactions show prints the tags, and an override names its flagged form by tag. Records and overrides from before keep their unkeyed hashes, and those overrides still apply; show marks such a record. rekey-vault reseals the key with the rest of the store, so tags stay stable. Breaking, hatchling-broker: Judged and Override carry tag/flagged_tag, with their hash fields optional; output_gate::build takes the master key. (WO-011)
  • The broker wipes its copies of held secrets. The output and artifact gates read every held secret for each message or artifact they judge; each secret, and each form they look for (hex, base64, normalized), is now wiped when the gate is done with it. A fetched secret's reply, and each request frame (a stored secret's), are made at their final size and wiped once sent or handled. A test allocator scans every block these free for a canary secret in any of its forms. Breaking, hatchling-core: VaultOp::Store and VaultResult::Fetched carry their secret_b64 as a SecretB64, which is wiped when dropped and never shown by Debug; the wire format is unchanged. Breaking, hatchling-broker: held_secret_values returns Zeroizing values. (WO-011)
  • Security keys can be made to ask for their PIN. With HATCHLING_REQUIRE_KEY_PIN=true, vault-keyring enroll-keys and add-key enroll each key to ask for its PIN, as well as a touch, each time it opens the vault, and a hardware-key vault serves only if every key does (one that asks only for a touch is named; remove it and add it again). The key enforces it: under its PIN it answers from its verified-user secret (CredRandomWithUV), so a client that skips the PIN derives nothing that opens the vault, and the setting is bound into the key's wrap, so it can't be edited out of the keyring. vault-keyring status shows what each key asks. Breaking, hatchling-vault: Factor::Fido2 and KeyInfo have a field more; the Authenticator trait has hmac_secret_verified (with a default), and AuthenticatorError a variant more. (WO-011)
  • Keyring edits are locked, and none rests on a stale read. Every save takes an exclusive lock beside the keyring (<keyring>.lock) and writes only if the file is still what that keyring read, so two processes editing at once can't write back a spent backup code or TOTP step: the second is refused with "changed since it was read", and nothing is written. A failed save leaves the keyring as it was. (WO-011)
  • The vault wipes the copies of secrets it frees. File reads are sized once, so a growing buffer never frees a partial copy; the master key file is read into a fixed buffer; check and holds_envelopes read only a file's first four bytes; the TOTP seed, the keyring's text, and typed and shown backup codes are wiped when dropped. A test allocator scans every block the vault frees for a canary secret while it migrates, checks, unlocks and recovers. A TOTP factor's Debug never shows its seed. (WO-011)
  • A cell killed by seccomp hands the host no core dump. Every cell program is held to a core limit of one byte, soft and hard, before its filter goes on: the kernel aborts a dump to a crash handler on a pipe (apport, systemd-coredump) at exactly that limit, and writes no core file under a page. The filter refuses any change to the core limit from inside (reads of every limit still pass), and raising it takes a capability no cell holds. Before, a seccomp kill handed apport the cell's memory, any secret it fetched included, and hundreds of kills could exhaust a small machine. A broker started with a hard core limit of 0 now refuses to start cells, since 0 doesn't stop a pipe handler; raise it to at least 1. (WO-011)
  • rekey-vault rotates a keyring vault's key inside its keyring, and takes no key file there. Every way in at the vault's tier is wrapped around a fresh key: the file tier's KEK, the TOTP tier's seed, and every enrolled security key, each plugged in and touched (one that doesn't answer is named, and nothing changes). Backup codes are replaced by a new set, printed once, and every store is re-sealed. A crash part-way leaves the vault refusing to serve until rekey-vault is run again, which finishes it. Without a keyring, a key file is still needed. (WO-003)
  • Raising the tier gives the vault a new key. vault-keyring enroll-totp and enroll-keys rotate the key as rekey-vault does, re-seal every store, delete the file tier's KEK file and replace the backup codes, so a copy of the keyring, KEK file or codes from before opens nothing. Breaking, hatchling-vault: confirm_totp, enroll_hardware_keys and enroll_single_hardware_key return a Rotated for the caller to finish (rekey each store, then Keyring::finish_rotation); the keyring format is 3, which older builds refuse. Breaking, hatchling-broker: run_rekey_vault takes an optional key file, run_keyring the broker's Config, and enroll_totp, enroll_keys and command the store list. (WO-003)
  • rekey-vault re-seals every sealed store (the secrets, schedules, artifacts, spaces and redaction records), where before it left all but the secrets under the old key, and prints a line per store. It checks every store first, so an old envelope or a plaintext file stops it before anything is touched. Breaking, hatchling-broker: rekey_vault takes the store list and returns a report per store. (WO-003)
  • A keyring vault started without its keyring refuses to serve, where before it minted a fresh plain key and served with it. Adopting a keyring, and each keyring unlock, records it beside the key file; a start without HATCHLING_VAULT_KEYRING that finds that record, or finds sealed files and no key, stops and says to set it. A new vault still gets its key. hatchling-vault has holds_envelopes. (WO-003)
  • Breaking, the vault's files: run hatchling-broker migrate-vault after upgrading. Every sealed file (the secrets, schedules, artifacts, spaces and redaction records) is now a version-2 envelope that authenticates its store and its name, so a file renamed or moved into another store fails to open, as a tampered one does. The broker refuses to start while any store holds an old envelope, naming the files; migrate-vault now re-seals every store, one file at a time (re-run it if it's interrupted), where before it sealed only the secrets' plaintext. Breaking, hatchling-vault: migrate_plaintext is migrate, and it, rekey and SealedVault::open_in take a Store. (WO-002)
  • The egress allowlist refuses internal addresses. A destination (other than local:) that resolves at startup to a loopback, private, shared, link-local, unique-local, unspecified, multicast or cloud-metadata address now fails the allowlist, naming the entry and the address; an IPv4 address mapped into IPv6 counts as the IPv4 one. If one of yours is meant to be internal, write it internal:host:port. (WO-023)
  • Breaking, hatchling-broker: ApiConfig has a ui_socket field. The workspace version is 0.4.0.

Fixed

  • The frontend host serves only its own pages. It refuses a request not addressed to 127.0.0.1:<port> or localhost:<port>, and a WebSocket upgrade whose Origin isn't one of its own pages. Before, another site open in the browser could open the UI or operator channel (it never had the token, but it reached the broker's decoder and its connection limits), and a rebound name could fetch the UI's files.
  • A security key's PIN isn't shown as it's typed, and is read into memory that's wiped. So are the broker's UI and operator tokens.
  • What a client or cell sends can't forge a line in the broker's log. An error quoting a frame the broker couldn't parse is logged escaped.
  • A skill run's directory in the temp dir has a random name, so another user can't make it first and block the run.
  • The local API takes its socket's owner from the broker's own uid, rather than reading it back from the socket's path after binding (WO-021, part 1).
  • What the broker tells a cell stays generic. A coding-CLI run that fails on the broker's side (its binary unreadable or not the pinned one, a file that can't be staged, the Codex host copy, the network set-up, the work tree unreadable) now reports a fixed sentence; the host paths, the broker's process id and the OS error stay in the broker's log. A malformed hello is rejected with a fixed sentence rather than the parser's message.
  • Strings a cell or an API client chose are logged escaped, so a newline in one can't forge a log line: a refused egress destination, a malformed skill id, a refused delivery target. A skill id holding a control character names no skill.
  • The operator's redactions review shows control characters, terminal escape sequences among them, as escapes: the text it prints was chosen outside.
  • Memory names read back from the store are checked as names given are.
  • A coding-CLI run's directory gets a name no one can guess, so another local user can't pre-create it to block the run.
  • The coding-CLI relay's policy refusal says 403 Forbidden, not 403 Service Unavailable.