Skip to content
tentaflake docsv0.4

Changelog

Source: CHANGELOG.md at 2081f31a099e · docs version 0.4.0

All notable changes to tentaflake are documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

  • OpenCode support is removed completely: its builder, image pin, generated services, CLI discovery, examples, tests, and current documentation are no longer part of the template. Version 0.4 supports Hermes and ZeroClaw; OpenClaw is intentionally deferred.
  • Phase 0 removes the Go tentaflake-auditd package, SQLite event database, custom Agent Console, tentaflake top, and the interactive agent wizard. The operator CLI is now a Rust workspace package verified with Rustfmt, Clippy, and Cargo tests.
  • The live-agent ISO and its firstboot/persistence scripts are removed. The installer ISO is the only bootable image.
  • Editor, Hive Research, and Piper TTS leave the core import set and are exported as explicit optional modules.
  • Installed systems now default to tentaflake.security.profile = "balanced". Direct credential env files, host networking, published ports, dashboards, extra devices/networks, sensitive mounts, and mutable images fail evaluation in a secure profile. Existing trusted development systems must explicitly select dev; strict fails closed until a tested separate-kernel boundary exists.
  • Automatically started balanced agents now fail evaluation unless their exact container key has an enabled broker, disposable worker, and fixed-size workspace quota. Deliberately stopped migration scaffolds may remain offline.
  • tentaflake.networking.egress is renamed to tentaflake.networking.legacyPortEgress and is dev-only. It was only a host OUTPUT port filter, not a destination allowlist or container FORWARD policy.
  • just security now combines a pinned, telemetry-free Semgrep source scan with current OSV checks for the Rust and Dev Containers CLI dependency lockfiles; GitHub Actions referenced by scanned workflows are commit-pinned.
  • Contributor E2E recipes now cover the complete automated gate, a frozen-lock Dev Container smoke test, and an interactive installer/installed-system UEFI VM backed only by an isolated persistent QCOW2 file.
  • A digest-pinned Dev Container can bootstrap Nix and preload the repository’s lock-file-backed development shell without mounting runtime sockets or credentials, giving local editors and Codespaces the same contributor toolchain as nix develop.
  • A complete threat model covering assets, adversaries, trust boundaries, control/evidence mapping, explicit non-goals, residual risks, and the deployment acceptance checklist.
  • A per-agent disposable Rust tool-worker: descriptor-safe bounded workspace snapshots, Nix-built offline toolchain image, short-lived no-network gVisor capsules, read-only result import, cleanup, log/audit limits, action classes, and host approval/denial over a private immutable pending request.
  • TFSEC-020 makes a missing disposable worker a high-severity posture finding for secure-profile controllers; module and VM tests cover mismatched policy, no-egress execution, forbidden/approval-required actions, and cleanup.
  • Fixed-size per-agent ext4 workspace volumes with fail-closed empty-workspace creation, exact-size drift rejection, checked mount/ownership dependencies, VM ENOSPC coverage, migration documentation, and posture finding TFSEC-021.
  • Explicit Docker docker-default AppArmor selection for secure controllers and workers, unconfined Seccomp/AppArmor override rejection, posture findings TFSEC-022/TFSEC-023, and documented Podman evidence limits.
  • Optional per-agent Cosign key/keyless verification units that gate OCI startup, an all-secure-agents enforcement switch, module tests, and posture finding TFSEC-024 when no signature policy is configured.
  • Opt-in systemd hardware-watchdog settings with no assumed device and an explicit target-host validation warning.
  • Observability textfile metrics and Prometheus rules for broker policy/fetch denials, request/token/cost budgets, restart flapping, and root-disk pressure.
  • Security-doctor live probes for root-disk pressure, Tailscale Serve/Funnel, and Restic freshness, including explicit unknown-evidence warnings and a hardened post-success backup timestamp unit.
  • Non-interactive live OCI inspection for the core privilege, user, runtime, network, capability, root-filesystem, resource, and mount invariants, with unavailable evidence kept explicitly unknown.
  • Podman live inspection now uses its documented OCIRuntime, effective and bounding capability sets, AppArmor profile, and compatible HostConfig fields; missing version-specific fields remain unknown instead of false-critical.
  • The VM integration definition now includes a Podman node that validates its generated secure unit, deterministic internal bridge, local image loading, runsc, non-root/empty capabilities, read-only root, limits, and no egress.
  • Security manifests now carry exact configured broker modes/endpoints; the doctor distinguishes unreachable evidence from an explicit failed credential/policy/audit health response and detects manifest inconsistency.
  • VM negative tests now include a separate external attacker node, actual runsc execution, direct DNS/Internet/private/metadata denials, PID and worker-runtime ceilings, sensitive-state invisibility, and a successful scoped LLM-broker request from the isolated agent network.
  • VM acceptance coverage now also exercises host loopback, the individual private/tailnet/link-local/multicast classes, /usr immutability, a changed Git remote, privileged-request denial, unsafe-doctor exit status, and a controlled reboot. Docker live inspection confirms CPU/RAM/swap/PID limits; redirect and DNS-rebinding policy uses a deterministic resolver fixture in the Rust suite.
  • Broker networks now use deterministic bridge interfaces; host INPUT accepts broker ports only when interface and source subnet both match, preventing a source-address-only allow rule from acting as the authority boundary.
  • Contributor E2E now uses a source- and dependency-hash-pinned Dev Containers CLI 0.88.0 with the upstream proxy-from-env WHATWG URL parsing change backported for its CommonJS consumer, removing the legacy url.parse() path that produced Node.js DEP0169 security deprecation warnings.
  • The installer ISO now embeds its repository below /etc/tentaflake/source, avoiding a parent-path collision with generated runtime manifests such as /etc/tentaflake/security.tsv.
  • The development shell now uses the canonical nixfmt package instead of its deprecated nixfmt-rfc-style alias, removing the flake evaluation warning.
  • The local just ci gate no longer stops on existing Statix and Deadnix findings; repeated Nix attribute paths are grouped and unused arguments are removed without changing their resulting module options.
  • Security-manifest generation now records an OCI container with no configured user as not non-root instead of passing its null user to a string matcher and failing evaluation in the dev compatibility profile.
  • The VM integration test now starts its rebooted node with QEMU reboot support enabled and asks systemd to reboot directly instead of relying on a virtual key combination. Its headless agent fixtures also omit kmscon, which can crash on QEMU’s synthetic bochs DRM device, and the stopped-controller fixture now declares the broker that its reboot assertion expects to return. The controlled-reboot assertion no longer waits indefinitely or checks an undeclared boot service.
  • The hardened backup-success timestamp service now declares its persistent path with systemd StateDirectory instead of requiring a nonexistent ReadWritePaths target before its script could create it. This prevents a 226/NAMESPACE failure after an otherwise successful Restic run, and the VM restore and broker-restart probes now report failures within 30 seconds instead of waiting for the global 15-minute retry timeout.
  • Disposable workers now select backend-compatible bounded log drivers (local for Docker and k8s-file for Podman) instead of sending file rotation options to a journald default that rejects them. Docker disables compression for its single retained local log file, as required by Engine.
  • Disposable host snapshots now remain root-owned while source-owner read and execute access is mirrored to the declared capsule GID for their read-only bind mount. This keeps private ext4 entries such as lost+found readable without adding CAP_CHOWN to the host worker. Capsule copies still discard host ownership metadata, and VM worker failures now print service diagnostics within 30 seconds instead of waiting for the global 15-minute retry timeout. Direct root approve and deny commands adopt that same declared worker group before touching state, so approved jobs do not create root-group snapshots that their non-root capsule cannot read.
  • Disposable artifact export now happens before the wrapper exits, while its bounded workspace tmpfs still exists. Artifacts stream from inside the gVisor mount namespace instead of relying on an OCI cp view that cannot see that tmpfs. A bounded importer rejects unsafe paths, links, and special files; the stable completion-marker handshake preserves the real command status without adding a writable host bind before releasing and removing the capsule.
  • gVisor VM probes now keep a small but startable PID budget. The previous limit of 16 charged runsc sandbox helpers against the same cgroup and could reject sandbox startup before the test workload ran.
  • Disposable-worker VM fixtures now request half a CPU, allowing their secure capsules to run inside the integration test’s single-vCPU guest while the production default remains unchanged.
  • Path-activated disposable workers no longer use systemd’s aggregate service start counter, which also counted successful oneshot queue drains and disabled a healthy worker after five ordinary jobs. Infrastructure failures retain the ten-second restart delay, while per-job resource and timeout limits remain.
  • Broker health and authentication now recognize systemd’s read-only named-ACL access for LoadCredential files used with DynamicUser. The ACL mask is accepted only for exact files below $CREDENTIALS_DIRECTORY; ordinary group-readable files and all other-readable files remain rejected.
  • VM broker-readiness checks now fail after 30 seconds with the raw health response, route, listener, firewall chains, unit status, and journal instead of waiting for the test driver’s 15-minute timeout.
  • Disposable worker units now use a statically declared host group matching the container GID, preserve tmpfiles’ setgid result mode without a blocked chmod, and inspect NixOS’ generated OCI start script when testing secure runtime flags.
  • VM secure-unit assertions now inspect NixOS’ generated OCI start scripts for both Docker and Podman instead of expecting runtime flags in wrapper units.
  • Disposable workers now retain openat2 as the primary no-symlink workspace opener and use a descriptor-relative, component-by-component openat fallback only when the kernel reports ENOSYS. Relative paths, dot/parent traversal, symlinks, and non-directory components remain rejected.
  • VM broker readiness assertions now retry the actual health response instead of treating systemd’s Type=simple process start as socket readiness.
  • Quota-backed workspaces now mount after ordinary local filesystems without a local-fs.target/basic.target ordering cycle; worker path watches start only after mounted ownership and control-directory setup completes.
  • Broker health probes no longer append audit events; startup proves audit writability once, later readiness checks reject symlinks and sync without allowing health polling to churn the bounded audit history. LLM input-token budgets now reserve the conservative request-byte upper bound, and bridge interface hash collisions fail evaluation.
  • Secure controller restart/dependency overrides now merge into the actual OCI-generated systemd service attribute instead of a parallel key ending in .service; broker and workspace-mount ordering is therefore present in the rendered container unit.
  • Per-agent Phase B brokered egress: dedicated internal OCI networks, disabled container DNS/IPv6, subnet-scoped host INPUT/FORWARD firewall rules, and a random runtime-only virtual credential per agent.
  • A Rust LLM credential broker with exact route/model allowlists, completion clamps, concurrency/rate/daily token/cost budgets, provider credential substitution through systemd LoadCredential, DNS pinning, no redirects or environment proxies, and prompt-free JSONL audit.
  • A Rust SSRF-safe fetch broker with exact HTTPS host/media allowlists, public IP enforcement, redirect revalidation, DNS-rebinding defense, bounded responses, mode-0700 quarantine, and explicit untrusted-content envelopes.
  • Bounded systemd restart policy and credential/audit readiness endpoints for brokers, plus a combined tentaflake stop transaction that stops an agent’s container and all loaded broker endpoints.
  • An opt-in encrypted Restic backup module with explicit state/audit paths, runtime-only repository/password files, retention, integrity checks, and a VM backup/restore drill.
  • A loopback-only observability profile with Prometheus, Grafana, Loki, Alloy, systemd credential loading, and provisioned data sources.
  • A separate Falco modern-eBPF runtime-detection profile. Consumers must supply a reviewed pinned Falco package because the pinned nixpkgs has none.
  • Direct module-evaluation coverage for core, observability, and Falco profiles.
  • One post-merge container security policy shared by Hermes and ZeroClaw: explicit non-root user, no privileges/capabilities/ports/devices, read-only root, bounded hardened tmpfs, CPU/RAM/swap/PID/ulimit controls, sensitive-mount and secret-like-environment rejection, and declarative gVisor runsc selection without a silent runc fallback.
  • tentaflake doctor --security with stable machine-readable TFSEC-* findings and a Nix-generated desired-state manifest.
  • Secure profiles require the private Tailscale management path, reject the public OpenSSH module, and advertise the documented tag:agent-host tag.
  • Exact canonical GitHub remote and branch allowlists for host-side gitAutoPush, repository-scoped safe.directory, fail-closed errors, and malicious-URL regression tests.
  • tentaflake status now inventories every non-empty physical disk instead of showing only the root filesystem. A mounted disk is reported once using / or its first data-like mount (excluding /boot and /nix/store); an unmounted disk remains visible by device name and raw capacity. Loop, RAM, zram, and zero-byte card-reader devices are excluded. The screenshot-safe --hide view keeps this non-identifying capacity telemetry, and tentaflake-status --selftest covers root, mounted data, unmounted, and excluded-device cases with synthetic commands.
  • tentaflake health — host vitals dashboard, the machine-level counterpart to stats. Same renderer again (logo, header, bars, colour thresholds), with a VITALS section — 24-cell bars for CPU busy (a 0.4 s /proc/stat delta, 100% = every core busy), memory, swap, the hottest thermal zone in °C, and every block-device filesystem — and a CHECKS section covering failed systemd units, agent states and Tailscale. The header carries a ● healthy / ▲ degraded / ✗ critical verdict, the worst finding on the same thresholds the bars are painted with. --live (default 2 s, --live=5 to slow it) redraws in place on the alternate screen, so Ctrl-C leaves the scrollback untouched and colours survive — unlike watch. It always exits 0: tentaflake doctor remains the scriptable check with the nonzero exit. --hide applies here too and prints a count of failed units instead of their names (a unit name carries its container’s); tentaflake-status --selftest now renders the masked health view alongside the masked wide view and fails if either leaks. ./scripts/banner-test.sh --health previews and regression-checks it on a dev box.
  • tentaflake stats — fleet dashboard sharing the login banner’s renderer (same logo, header and rows, so the two cannot drift), one density wider: per-agent CPU%, PID count, memory against the container limit, and a fleet total against host RAM. CPU is a 0.4 s cgroup delta on the docker stats scale (100% = one busy core); for a live view, watch -cn2 tentaflake stats.
  • Per-agent memory column in the login banner. Read straight from each container’s cgroup (memory.current - inactive_file, the exact number docker stats reports) instead of docker stats’ ~2 s sample window, so login stays fast. Known docker and podman cgroup layouts are probed; an agent without a readable cgroup simply gets no cell.
  • --hide / -H on status and stats: masks host name, tailnet IP and agent names (agent-1agent-N, the failed-agents hint included) for screenshot-safe output, with a yellow · redacted header marker. Telemetry stays. tentaflake-status --selftest renders the masked wide view and exits nonzero if any identifying string leaks.
  • DCO enforcement for pull requests: every non-merge commit must include a Signed-off-by: line; see DCO.txt and CONTRIBUTING.md.
  • nix develop now greets you: the dev shell prints the tentaflake logo with branch/HEAD/working-tree/Nix-version rows and a short just cheat sheet, and sets a (tentaflake) prompt marker. Same braille art and layout as the host login banner — both read public/tentaflake-shell-logo.txt. The banner is skipped when stdout is not a TTY (nix develop --command … in CI stays clean) and TENTAFLAKE_NO_BANNER=1 disables it entirely. The dev shell moved from an inline pkgs.mkShell in flake.nix to lib/devshell.nix; the package set is unchanged, and the banner script is built with writeShellApplication, so nix flake check shellchecks it.
  • Default hostname and admin user renamed: agent-hosttentaflake, adminuser (lib/constants.nix). The built-in host’s flake attribute follows the hostname (it is now written nixosConfigurations.${hostName}, so the two cannot drift), which means nix build .#nixosConfigurations.agent-host and nixos-rebuild --flake .#agent-host no longer resolve — use .#tentaflake. Same for the check attr checks.<system>.agent-hostchecks.<system>.tentaflake. Installed machines are unaffected: the installer generates nixosConfigurations.${uc.hostName} from the hostname you typed, so tentaflake rebuild keeps working. The installer’s suggested defaults also change (agent-machinetentaflake, agentuser); both remain editable in the wizard.
  • The fleet-generic infrastructure drops its hermes- branding (#52); “Hermes” now only names the Hermes agent runtime. What renames, and what bridges the transition for one release:
    • systemd units: hermes-auditdtentaflake-auditd, and on the live ISO hermes-env-detect/hermes-data-mounttentaflake-env-detect/tentaflake-data-mount. The old unit names are gone — update systemctl/journalctl scripts. (The console unit was already tentaflake-console.)
    • module options: tentaflake.hermes-auditd.*tentaflake.auditd.*. Old names still evaluate with a deprecation warning via lib.mkRenamedOptionModule and will be removed in a future release.
    • flake package attr: packages.hermes-auditdpackages.tentaflake-auditd. The old attr remains as a deprecated alias to the same derivation.
    • binaries: hermes-auditdtentaflake-auditd, hermes-toptentaflake-top (tentaflake top now execs the new name). The package installs a deprecated hermes-top symlink for one release.
    • USB labels (live ISO unattended boot; the interactive tentaflake agent add needs no label at all): TENTAFLAKE_ENV/TENTAFLAKE_DATA are the primary labels; the legacy HERMES_ENV/HERMES_DATA labels are still accepted, so existing sticks keep working.
    • live-ISO env dir: /run/hermes/run/tentaflake, with a /run/hermes compat symlink for configs that hardcode the old envFile path.
    • Go module (fork-relevant only): tentaflake/hermes-auditdtentaflake/tentaflake-auditd; internal/hermesinternal/event.
    • NOT renamed (deliberately, so existing deployments keep their audit history): the audit DB state dir /var/lib/hermes-audit, the default dbPath /var/lib/hermes-audit/events.db, and the hermes-audit user/group. These rename in a future major with a migration.
    • Also: the hermes-<name>/zeroclaw-<name> container/user/state-dir prefixes, the HERMES_* env contract, and mkHermesAgent are unchanged — they name the Hermes runtime, not the infrastructure.
  • Digest pinning is enforced, not merely defaulted (lib/pinnedImage.nix): a previously-valid image = "repo:tag" is now a hard eval throw in all three builders (mkHermesAgent, mkZeroClawAgent, mkOpenCodeAgent). Any fork or consumer flake that overrides image with a tag fails nixos-rebuild outright, before a single derivation is built. Repin to repo@sha256:<digest> (the error message prints the skopeo inspect command that reports it), or set allowMutableImage = true on that agent to acknowledge giving up reproducibility. Note that repo:tag@sha256:... is rejected too — keep the version in a comment, not in the reference. See Security below for the full rationale.
  • The physical console is rendered by kmscon on TTY1 by default on installed systems (tentaflake.modernConsole.enable, default true). On hardware where kmscon cannot grab the framebuffer the operator loses the physical console — there is no local login left to fix it from, only Tailscale SSH or a rescue boot. On unfamiliar hardware, check the console on the first rebuild while remote access is still up; tentaflake.modernConsole.enable = false; falls back to the legacy VT. Both ISOs are unaffected — they keep the VT deliberately. See Added below.
  • The live agent ISO’s debug sshd no longer accepts password auth (PasswordAuthentication = false). Logging into a booted live ISO with the live user’s password stops working — use Tailscale SSH, or the key-only modules/ssh.nix with tentaflake.adminAuthorizedKeys. See Changed below.
  • The host Nix daemon is locked down (modules/nix-settings.nix): allowed-users is now root and @wheel only, so any non-wheel user or service account that used to run nix build/nix-store against the host daemon is refused outright; and sandbox-fallback = false turns a build that cannot be sandboxed into a hard failure instead of a silent unsandboxed one. See Changed below.
  • tentaflake-auditd hardening (#8): the audit DB now sets secure_delete and is capped at 10000 pages (~40 MB) so an agent flooding filesystem events cannot fill the host disk; inotify directory watches are capped at 10000 per daemon (with a one-time warning when hit); the console clamps ?limit= to 1000 and every store read is bounded by a 5 s query timeout. (No fs.inotify.max_user_watches sysctl needed — nixpkgs already defaults it to 524288.)
  • Default Hermes, ZeroClaw and OpenCode container images, plus the documented Nix build image, are pinned to exact multi-platform OCI manifest digests (lib/constants.nix). Identical configurations no longer resolve mutable tags to different image contents across machines or over time. Refresh the pins with ./scripts/update-agent-images.sh (it prints upstream digests for all three runtimes; the bump stays a manual edit).
  • Digest pinning is now enforced, not just defaulted (lib/pinnedImage.nix). mkHermesAgent, mkZeroClawAgent and mkOpenCodeAgent reject an unpinned image at eval time, so a fork cannot silently switch back to a mutable tag; the check runs after the extraContainerConfig merge, which could otherwise override image past it. Locally-built images opt out per-agent with allowMutableImage = true. Covered by the new image-pinning flake check.
  • Image references are also character-whitelisted, including under allowMutableImage. nixpkgs’ oci-containers module splices container.image into the container unit’s shell command without escapeShellArg (unlike every neighbouring element), so a reference carrying whitespace or a shell metacharacter could inject extra docker run flags — or a command substitution running as root — behind a digest-shaped suffix.
  • Pinned references are written registry/repository@sha256:digest, never repository:tag@sha256:digest. The docker CLI accepts the tag-plus-digest form but podman and skopeo reject it outright (“Docker references with both a tag and digest are currently not supported”), which would have broken every tentaflake.containerBackend = "podman" host.
  • CI workflow hardening (.github/workflows/check.yml): a top-level permissions: contents: read, and all five third-party actions pinned to full 40-character commit SHAs with a trailing version comment. GITHUB_TOKEN is otherwise write-capable on pushes while every step in this workflow only reads the repo, and a tag like @v4 is re-pointable by whoever owns the action — an unpinned action is arbitrary code execution in a job that holds the repo token. golangci-lint-action moves v6 → v9.3.0 in the same pass rather than being pinned where it was: v6 only drives golangci-lint v1, and this repo’s .golangci.yaml is the v2 format, so pinning v6 would have failed outright. The linter itself is pinned to v2.12.2 instead of latest, so an upstream lint release can no longer break CI on an unrelated PR. (v9 additionally validates .golangci.yaml against its JSONSchema, which v6 skipped — worth watching on the first run.)
  • mkOpenCodeAgent — a third agent runtime builder (OpenCode) alongside mkHermesAgent/mkZeroClawAgent. Runs opencode serve (headless HTTP, documented OpenAPI 3.1) in the same isolation contract (own container/uid/0700 state dir + /workspace, secrets via --env-file), forwarding a loopback hostPort to the gateway with an optional tailscale serve on servePort. Ideal for external orchestrators (n8n, CI): POST /session then POST /session/<id>/message. It joins the other two runtimes’ safety contract: the image defaults to a digest pin in lib/constants.nix and goes through the same enforced pinnedImage check (mutable tags are an eval error; allowMutableImage = true opts a locally-built image out). Setting servePort now requires an envFile or agenixFile — the container is started with --hostname 0.0.0.0 so the gateway is reachable across the container boundary, and opencode serve only turns on HTTP basic auth when OPENCODE_SERVER_PASSWORD is set (username defaults to opencode), so publishing without one put an unauthenticated agent on the tailnet; the assertion checks the env file is wired up, and allowUnauthenticatedServe = true is the explicit acknowledgement. Because Nix cannot read a runtime/agenix file at eval time, the serve unit re-checks at start: an ExecStartPre greps the env file(s) for a non-empty OPENCODE_SERVER_PASSWORD and refuses to publish without one — so the documented proxy-key pattern (an env file carrying only an LLM key) fails closed instead of satisfying the assertion and publishing anyway. It also runs tailscale serve --https=<servePort> off on that path, because tailscale serve --bg state is persisted by tailscaled and restored on boot: dropping the password de-publishes the agent rather than leaving a stale mapping in front of a now-unauthenticated session API. The password must live in envFile/agenixFile and not extraEnvironment — that attrset is rendered into the container unit’s start script in the world-readable Nix store. The tailscale serve unit also gained an ExecStop (tailscale serve --https=<servePort> off), so removing or stopping an agent tears its tailnet mapping down instead of leaving a dangling public endpoint pointed at a dead port. Credentials via a local LLM proxy (settings base_url + env-file key, host-portable) or an opt-in read-only authFile. Exported from lib.<system>.mkOpenCodeAgent and passed to my-agents.nix (older files taking only the previous builders keep working via intersectAttrs). Wired into the same multi-runtime plumbing as the other runtimes: the tentaflake CLI/status banner discovery, the tentaflake-auditd state-dir watcher (Go agentNameFromPath + NixOS auto-discovery), and the installer’s env-placeholder/USB-persistence loops all recognize the opencode- prefix; the VM integration test now declares an OpenCode agent and asserts its unit + 0700 state dir. See docs/08-opencode.md and my-agents.nix.example.
  • Modern physical console (tentaflake.modernConsole.enable, on by default): the installed system renders TTY1 with kmscon (KMS/DRI + pango) instead of the legacy Linux VT, so the console gets real TTF fonts and full Unicode. A Linux VT maps at most 512 glyphs and the kernel’s built-in font ships 256 CP437-era ones, so on the machine’s own screen the braille logo in tentaflake-status and btop’s graphs rendered as boxes — no console.font could fix that, the ceiling is the VT. Default font is Cascadia Mono (one of the few modern monospace fonts covering U+2800–28FF; JetBrains Mono, Hack and Fira Mono do not), size via tentaflake.modernConsole.fontSize (default 14). The keymap follows tentaflake.consoleKeyMap through xkb, since kmscon replaces getty. Disable it on hardware where kmscon cannot grab the framebuffer — the legacy VT then uses the new tentaflake.consoleFont (Terminus ter-v16n by default, still far better than the kernel font for box drawing). consoleFont applies only when kmscon is off, and null skips the boot-time setfont entirely; both ISOs use null, because their dialog TUIs draw in ASCII anyway and the fbcon reconfiguration flickers on some Intel panels (pipe A FIFO underrun). Both ISOs deliberately keep the legacy VT: kmscon hands the login a pty and owns the VT in graphics mode, which would defeat the [ "$(tty)" = /dev/tty1 ] guard that auto-launches installer.sh and the live ISO’s firstboot wizard (the latter also writes straight to /dev/tty1). See docs/06-shell.md#physical-console-kmscon.
  • tentaflake agent add|list|set-model|remove — an interactive CLI wizard for declaring Hermes and ZeroClaw agents without writing Nix. Config lands in a flat, non-secret, git-tracked agents.json (schema v1: name/provider/model/base_url/ports/envFile path) read by lib/agentsFromData.nix, additive to a hand-written my-agents.nix. API keys are read with read -rs (or imported from a USB stick, see below) and written only to a root-owned 0600 file at /var/lib/tentaflake/secrets/<runtime>-<name>.env — never into agents.json or the Nix store. See docs/08-agent-cli.md.
  • USB key import in tentaflake agent add — nobody wants to type a 73-character OpenRouter key. The wizard searches already-mounted media (/run/media/*/*, /media/*, /mnt/*) and, only if that finds no key, unmounted removable partitions which it mounts itself ro,nosuid,nodev,noexec; it looks two directories deep for .env files carrying the provider’s KEY= line and for .txt/.key files holding a bare token. No filesystem label and no filename convention are required — the TENTAFLAKE_ENV label is unchanged and undeprecated, it stays the deterministic marker for the live ISO’s unattended boot and here merely ranks a volume first. Findings are presented with a masked preview (first 6 + last 4 + length) and their source volume/file, never silently consumed; “type or paste it myself” is always available and the wizard works with no stick at all. Files on removable media are parsed with a regex, never sourced, evald or executed, and every mount is released on abort, error or Ctrl-C. Typed keys now also get a masked echo-back and a retry, so a truncated paste is visible.
  • Pinned logo during tentaflake agent add — the logo stays at the top of the terminal for the whole wizard using the terminal’s own scroll region (DECSTBM CSI top;bottom r), no curses and no new dependency; tput is unusable here because the installed console is kmscon and TERM=kmscon has no terminfo entry, so geometry comes from stty size. Restored via an EXIT/INT/TERM/HUP trap. Degrades to plain sequential output when not on a tty, under NO_COLOR, or on TERM=dumb; a window under 24×60 (or a legacy Linux VT, which has no braille glyphs) gets a compact two-row header instead, down to 12 rows × 40 columns. Colour is now gated globally on the tty / NO_COLOR / TERM=dumb conditions, independent of window size, so all tentaflake output is clean when piped.
  • NixOS VM integration test (checks.x86_64-linux.vm-integration, tests/integration.nix): boots a VM built from nixosModules.default and asserts the runtime path — the tentaflake CLI runs (help, ps), the status banner renders and names the host, tentaflake-auditd comes up and creates its SQLite event DB, and each declared agent produces its systemd unit and 0700 state dir (the Hermes agent also its system user; the OpenCode agent runs as an anonymous uid, so it has none, and gets its /workspace checked instead). The daemon subtest waits for /var/lib/hermes-audit/events.db rather than a port, because tentaflake-auditd opens no socket at all — it writes events to SQLite and the separate tentaflake-console service is the HTTP surface — and Type=simple marks the unit active before the binary has run, so wait_for_unit alone would race the DB creation. Agents are declared autoStart = false so the sandboxed, offline VM never tries to pull an image: the test proves each unit is defined, not that a container runs. The VM node pins its own hostName/adminUser (agent-host/admin) instead of inheriting the template defaults, so the banner assertion is unaffected by the agent-hosttentaflake rename above; it also sets nixSettings.enable = false, since that module defines nixpkgs.config.allowUnfree and the test harness declares nixpkgs.config read-only. Runs as part of nix flake check and CI. Previously CI only proved the config built; nothing exercised runtime behavior.
  • CI now runs golangci-lint on pkgs/tentaflake-auditd (.github/workflows/check.yml), enforcing the lint standard the contributor docs already required.
  • tentaflake CLI host-management subcommands (#51): rebuild (nixos-rebuild switch on the system flake — same command as the rebuild alias), update (nix flake update on /etc/nixos, shows the flake.lock diff, asks y/N, then rebuilds), doctor (deep health check — failed systemd units, root disk ≥90%, Tailscale, tentaflake-auditd/tentaflake-console service state when enabled, per-agent unit state — every problem paired with its exact fix command; nonzero exit when problems found), console (Agent Console URL + the tailscale serve publish one-liner, or how to enable the console when it’s off), and backup <name> (one-shot sudo tar snapshot of the agent’s state dir to ./tentaflake-<name>-<UTC timestamp>.tar.gz, with an active-agent consistency warning and the matching restore one-liner). Agent records now carry each agent’s state dir, derived from the container’s first volume mount (so custom stateDirs are honored).
  • Opt-in egress filtering (tentaflake.networking.egress.enable, default off): an nftables output chain (table tentaflake-egress, policy drop) allowing loopback, established/related, ICMP/ICMPv6, and configurable outbound ports (allowedTCPPorts default [443], allowedUDPPorts default [53 67 123 547 41641] — DNS, DHCP, NTP, DHCPv6, tailscale). Because agent containers use host networking, the host rules cover the whole fleet. (#9, docs)
  • modules/ssh.nix — opt-in hardened OpenSSH server (tentaflake.ssh.enable, default off): key-only auth (no passwords, no keyboard-interactive, no root login, max 3 auth tries), fail2ban, and TCP 22 opened in the otherwise deny-all firewall. Tailscale SSH remains the primary access path; admin keys come from tentaflake.adminAuthorizedKeys.
  • CI security workflows: CodeQL analysis of the Go code (.github/workflows/codeql.yml, push/PR to main + weekly), gitleaks full-history secret scanning (.github/workflows/gitleaks.yml), and a weekly flake.lock update PR via DeterminateSystems/update-flake-lock (.github/workflows/update-flake-lock.yml).
  • .pre-commit-config.yaml — optional local hooks mirroring the CI gates (gitleaks, shellcheck, gofmt, go vet, nix fmt); setup documented in CONTRIBUTING.md.
  • .agents/skills/tentaflake-change-review/ — a bundled skill carrying this repo’s change-review discipline, alongside tentaflake-repo-guidance. It borrows one idea from the heavyweight AI-engineering frameworks — traceability: every change ties back to a stated reason (issue/requirement) and forward to a verification step, with doc sync before it counts as done — and strips the ceremony. Three gates, no lifecycle, no review matrices; sized for a ~30-file NixOS template maintained by Conventional Commits and PRs, and explicitly skipped for formatting, typo and comment-only edits. Its target is orphaned changes (code with no stated reason), silent assumptions and doc drift.
  • scripts/banner-test.sh (also just banner) — renders the tentaflake-status banner with a stubbed systemctl and a fake mixed-runtime fleet (active/inactive/failed) so the banner can be previewed and regression-checked on any dev machine; self-checks cover fleet counters, duration formatting, logo loading, and logo/info-column alignment. The wizard’s own logic is covered by the hidden tentaflake agent __selftest (env/bare key parsing including a “must not execute” fixture, masking, header geometry and degradation, scroll-region teardown).
  • Live agent ISO: the debug sshd no longer accepts password auth (PasswordAuthentication = false) — Tailscale SSH covers remote debugging on the live ISO.
  • pidsLimit parameter on mkHermesAgent and mkZeroClawAgent — renders --pids-limit=<n> on the agent container (default 512, generous because agents compile code; null skips the flag). Hermes containers additionally gain --security-opt=no-new-privileges:true by default (ZeroClaw containers already had it). Opt-in --cap-drop=ALL recipe and the docker-group-is-root-equivalent tradeoff (podman as the rootless alternative) documented in docs/02-agent-tips.md and docs/07-operations.md. (#3)
  • Kernel hardening (#4): hardening.nix gains sysctls (kexec/sysrq off, BPF JIT hardening, TCP RFC 1337, ICMP broadcast/bogus-error ignore, loose rp_filter — Docker-compatible, martian logging, ARP ignore/announce) , boot.kernelParams (slab_nomerge, init_on_alloc/free, pti=on, vsyscall=none, debugfs=off, randomize_kstack_offset=on), and an explicit LSM order via security.lsm (landlock,yama,apparmor,bpf); boot.nix disables the systemd-boot menu editor (blocks init=/bin/sh).
  • Git hygiene: .gitignore now covers age.key and *.log; CONTRIBUTING.md documents SSH commit/tag signing and signed release tags (git tag -s). (#10)
  • docs/04-agenix-secrets.md: “Key Rotation & Recovery” section — rotating secret values, rekeying out a compromised recipient (old ciphertext in Git history stays exposed), recovering from a lost host SSH key via any surviving recipient, and an example rotation cadence for forks. (#6)
  • SECURITY.md: operator incident response runbook — isolate the container, revoke provider keys, inspect the tentaflake-auditd audit trail (UTC timestamps), preserve the state dir, rotate secrets and rebuild. (#11)
  • docs/07-operations.md: Backup & restore (state dirs, audit DB via sqlite3 .backup — never cp a live WAL db — off-host age identity, restic example for forks) and Log forwarding (services.journald.upload snippet) sections. (#11)
  • modules/piper-tts-server.nix: systemd unit hardened to parity with tentaflake-auditd/hive-research (ProtectSystem=strict, ProtectHome, PrivateDevices, RestrictNamespaces, LockPersonality, empty capability bounding set, RestrictAddressFamilies, SystemCallFilter=@system-service) plus resource limits: TasksMax=64 and a new services.piper-tts-server.memoryMax option (default 2G — raise for larger voice models). (#12)
  • modules/nix-settings.nix: Nix daemon hardening — allowed-users restricted to root and @wheel (agents live in containers and never talk to the host daemon), explicit sandbox = true with sandbox-fallback = false (no silent downgrade to unsandboxed builds), and min-free/max-free (2 GiB / 8 GiB) so builds cannot fill the disk. (#5)
  • Live-ISO firstboot env detection genericized (#52): agent env-file placeholders are now created for docker-/podman- units of both runtimes (hermes-* and zeroclaw-*, previously hermes-* only), and the USB data-mount persists /var/lib/zeroclaw-* state dirs too. Hermes env files keep their bare names (coding.env); other runtimes keep the runtime prefix (zeroclaw-scout.env).
  • .golangci.yaml migrated to the golangci-lint v2 config format — the v1 file no longer loaded under golangci-lint 2.x, so lint was silently broken; the errcheck findings that had accumulated unseen are fixed.
  • tentaflake-status login banner redesigned: braille-art octopus-snowflake logo in cyan (embedded at build time from public/tentaflake-shell-logo.txt, the single source of truth) with the header and host facts rendered as a column to its right, and the container backend in the tagline; AGENTS header now cyan with a fleet count (total · active · inactive, plus failed in red when present); each runtime gets its own color (hermes yellow, zeroclaw blue, other magenta) on dot/runtime/status; agents sorted by name; active agents show their uptime (active 2d 4h); inactive agents render dimmed; a failed agent adds a red ⚠ failed: <name> — tentaflake logs <name> hint. Also: memory/disk lines gain usage-colored percentages (green/yellow ≥75%/red ≥90%), host uptime is read from /proc/uptime (fixes the duplicated load average in the old fallback), and a separator rule divides host facts from the agent list.
  • Installer: adding the first agent on an installed machine broke tentaflake rebuild with error: infinite recursion encountered. The flake installer.sh generates for the target passed only mkHermesAgent and constants in specialArgs, but configuration.nix also takes mkZeroClawAgent and agentsFromData — and consumes them inside imports. A missing helper there does not fail loudly: Nix falls back to config._module.args, which needs config, which needs imports, so the rebuild dies in recursion instead of naming the absent argument. It stayed hidden on a fresh install because lib.optionals (builtins.pathExists ./agents.json) leaves agentsFromData unforced until an agents.json exists — so the very first tentaflake agent add triggered it. The generated flake now splats the whole lib/default.nix helper set, so it cannot drift from what configuration.nix asks for again. Covered by the new scripts/generated-flake-test.sh. Machines already installed need a one-line manual patch — see docs/08-agent-cli.md.

  • Installer: nixos-install failed evaluating the target flake with a readFile error — the redesigned tentaflake-status banner reads public/tentaflake-shell-logo.txt from modules/shell.nix, but the installer never copied public/ into /mnt/etc/nixos. The installer now copies the logo file (just that file, not the multi-MB imagery in public/).

  • USB data persistence (tentaflake-data-mount) and the env-file placeholder loop (tentaflake-env-detect) never ran at all. Neither unit declared a path, so both got only the default service PATH (coreutils, findutils, gnugrep, gnused, systemd) — no blkid, mount/umount or awk. blkid exiting 127 was swallowed by the surrounding || true, leaving USB_DEV empty and tripping the [ -z "$USB_DEV" ] && exit 0 guard on every boot, so a TENTAFLAKE_DATA stick was silently ignored and agents never got their placeholder .env files (which is what made docker run --env-file fail on a cold boot). Both units now declare path = [ pkgs.util-linux pkgs.gawk ].

  • USB data persistence could destroy agent state (installer/firstboot.nix). tentaflake-data-mount used to mkdir -p the target on the USB stick, then rm -rf the real /var/lib/<runtime>-<name> and symlink it — with nothing verifying that the copy had landed or that the target filesystem could even hold Unix ownership. The target was created root-owned 0755, so an agent running as its own uid could no longer write its own 0700 state dir; on the vfat/exfat stick most people hand-make, “state dir is 0700 and owned by the container uid” quietly evaporated altogether. Any non-empty target was treated as authoritative, so resuming after a partially-copied stick replaced a complete local state dir with a truncated one and reported success. And on a full or read-only stick the rm -rf ran regardless, so the agent’s state was simply gone. Persistence now goes through a stage_to_usb() helper that mirrors the original uid/gid/mode onto the USB directory and re-stats to confirm it stuck (a filesystem that cannot hold Unix ownership is detected and skipped), then branches on the state that is actually present:

    • USB target empty — seed it from the local dir, verify by comparing entry counts, and only then remove the original and symlink.
    • Local dir empty — the USB copy wins and is symlinked in. This is the ordinary reboot path: tmpfiles recreated an empty state dir and the real state is on the stick, so there is nothing to lose.
    • Both sides hold staterefuse. Copying either way discards the other and nothing can know which the operator wants, so it logs … both hold state — refusing to discard either; resolve by hand and touches neither side. Resolving a conflict is never done by discarding.

    A failure leaves the original directory fully intact and logs WARNING: could not persist … — keeping local state, no USB persistence. The one path that cannot honestly claim that says so instead: if the rm -rf succeeded but the symlink did not, the state is on the stick, and the message is ERROR: … removed but symlink … failed — state is on the USB, relink by hand. The helper’s exit status is captured rather than left to errexit, so one unpersistable agent no longer aborts the whole loop, and it sits inside the existing loop, so all three runtimes (hermes-*, zeroclaw-*, opencode-*) are covered at once. Covered by scripts/data-mount-test.sh.

  • modules/piper-tts-server.nix: every POST /v1/audio/speech returned 500 piper binary not found while /health and /v1/models kept answering, so the service looked healthy and only synthesis was dead. The server shells out to piper via subprocess.run, but /run/current-system/sw/bin — where environment.systemPackages lands — is not on a systemd unit’s default PATH. The unit now declares path = [ pkgs.piper-tts ]. Pre-existing since the module landed in 0.2.0, not caused by this release’s sandbox hardening; found while reviewing it.

  • tentaflake re-enabled terminal echo on exit even when it had never disabled it. tf_cleanup runs from an EXIT trap on every invocation — including tentaflake help — and called stty echo unconditionally, corrupting any caller on a -echo terminal (serial console, scripted pty, the NixOS test driver’s backdoor console, where it desynced the driver outright). Echo is now restored only when a masked read -rs actually turned it off.

  • The host operator CLI is now tentaflake. The hermes host command remains in this release as a deprecated shim (prints a warning to stderr, execs tentaflake) and will be removed in a future release — update scripts and habits. (The hermes CLI inside Hermes agent containers is unrelated and unchanged.)
  • tentaflake.shell.hermesCli.enabletentaflake.shell.tentaflakeCli.enable. The old option still evaluates via lib.mkRenamedOptionModule with a deprecation warning and will be removed together with the shim.
  • my-agents.nix is now expected to accept { mkHermesAgent, mkZeroClawAgent }. Inside this template, configuration.nix only passes the arguments your file declares, so old { mkHermesAgent }:-only files keep evaluating — but flake-input consumers that call import ./my-agents.nix { inherit mkHermesAgent mkZeroClawAgent; } directly must update old files to the new signature (or add ...).
  • tentaflake ps behaves differently from the old hermes ps: it lists agent containers of all runtimes including stopped ones (--all + anchored name filters) instead of --filter name=hermes- on running containers only.
  • Agent Console / tentaflake top label non-Hermes agents with a runtime prefix (zeroclaw-<name>). Hermes agents keep their bare labels, so existing audit DBs and dashboards are unaffected.
  • lib/mkZeroClawAgent.nix — second agent runtime alongside Hermes: OCI container (ghcr.io/zeroclaw-labs/zeroclaw), TOML settings attrset generated into a read-only config.toml, a tailscale serve unit for tailnet HTTPS access, and an optional seedDir copied into the state dir on first boot. State dir /var/lib/zeroclaw-<name>, secrets via agenixFile (--env-file).
  • zeroclaw.env.example — mirrors hermes.env.example; documents the ZEROCLAW_<section>__<sub>__<key> env-var convention ZeroClaw uses for config overrides, with an OpenRouter api_key example.
  • my-agents.nix.example now takes { mkHermesAgent, mkZeroClawAgent } and defines both a hermesAgents list and a zeroclawAgents list (map mkHermesAgent hermesAgents ++ map mkZeroClawAgent zeroclawAgents), with a fully-commented generic ZeroClaw reference agent (schema_version, model, agentic runtime profile, supervised risk profile).
  • my-agents.nix.example: rewritten around a per-runtime agent list (now hermesAgents, see above) with a fully-commented generic reference agent documenting every settings option (model + fallbacks, auxiliary routing, compression, memory, toolsets, approvals, web/terminal backends, provider timeouts, TTS/STT) plus seed/volume/env/container fields. Active agent renamed defaultcoding to match the docs. Quickstart and agent-tips docs updated to the new structure.
  • modules/shell.nix: optional zsh (tentaflake.shell.zsh.enable) — Oh My Zsh + autosuggestions + syntax-highlighting + fzf-tab, with Starship as the prompt; becomes the admin login shell when enabled. Plus tentaflake.shell.zoxide.enable (smart-cd, cross-shell, default on) and tentaflake.shell.lazygit.enable (+ lg alias). Aliases moved to environment.shellAliases so they apply to bash and zsh.
  • modules/shell.nix: tmux (tentaflake.shell.tmux.enable, configured). Convenience aliases rebuild (→ nixos-rebuild switch --flake /etc/nixos#<host>), reload (exec $SHELL) and cls. tmux moved out of the tools package set into its own toggle.
  • modules/editor.nix + nixosModules.editor: optional Neovim via nvf (tentaflake.editor.nvf.enable) — LSP, treesitter, telescope, gitsigns, blink-cmp; lean language set (nix/bash/lua/markdown/yaml). Added the nvf flake input (kept out of nixosModules.default so external consumers aren’t forced to have it).
  • Installer: a feature checklist (zsh / zoxide / nvf / lazygit / modern tools) whose selections are written into the generated /etc/nixos/flake.nix as tentaflake.* toggles (nvf also injects the rev-pinned input + editor module import).
  • hermes-top — live TUI dashboard (bubbletea) of agent filesystem activity, launched via tentaflake top. Reads the hermes-auditd SQLite DB directly (no network surface — runs over Tailscale SSH). Implements the read side the daemon previously discarded (the internal/store already had the query methods).
  • hermes-auditd is now wired up: enabled by default on agent-host, watchDirs auto-derives from the agents defined in my-agents.nix, runs as an unprivileged hermes-audit user with only CAP_DAC_READ_SEARCH, and stores its DB group-readable so the admin can run tentaflake top without sudo.
  • internal/store: Since(afterID, limit) (incremental tail) and AgentRows(window) (per-agent activity summary) read helpers.
  • modules/shell.nix — operator shell experience for SSH/console: the agent-management CLI (tentaflake, see Changed), dynamic tentaflake-status login banner, Starship/bash prompt, completion, and a curated modern CLI tool set. Toggle via tentaflake.shell.*. See docs/06-shell.md.
  • modules/piper-tts-server.nix — local TTS via Piper (OpenAI-compatible /v1/audio/speech)
  • modules/hermes-firstboot.nix — USB env detection + first-boot TUI wizard
  • nixosConfigurations.live-agent + nix build .#live-agent-iso — bootable ISO with Hermes + TTS
  • Host operator CLI renamed hermestentaflake (same subcommands: status|logs|restart|start|stop|shell|exec|ps|top|help), now multi-runtime aware — tentaflake status/ps and the login banner list agents across all runtimes with a runtime column. A deprecated hermes shim still works: it prints a deprecation note to stderr and execs tentaflake.
  • tentaflake.shell.hermesCli.enable renamed to tentaflake.shell.tentaflakeCli.enable (old name still accepted via lib.mkRenamedOptionModule).
  • modules/hermes-auditd.nix: watchDirs auto-discovery widened from hermes-* containers to all declarative agent containers (hermes-*/zeroclaw-* prefixes), so ZeroClaw state dirs are watched too — unrelated oci-containers are still excluded. Auto-derived console roots keep bare names for Hermes (coding) and runtime-prefixed names otherwise (zeroclaw-assistant).
  • hermes-auditd 0.1.3: event attribution (agentNameFromPath) and hermes-top path shortening now recognize /var/lib/zeroclaw-<name> state dirs — ZeroClaw activity is labelled zeroclaw-<name> instead of lumped into unknown; Hermes labels stay bare for existing DBs.
  • flake.nix now exports mkZeroClawAgent alongside mkHermesAgent (lib.${system}, specialArgs); configuration.nix passes each builder to my-agents.nix only if the file’s function asks for it (lib.functionArgs intersection), so old { mkHermesAgent }-only files keep working unmodified.
  • store.go: fixed event time-window comparisons (Stats, Prune) — stored RFC3339 timestamps were string-compared against SQLite’s datetime('now', …) (space-separated) form, which only agreed when the date differed, so same-day events outside the window were mis-counted and never pruned. Now normalized via datetime(timestamp).
  • cmd/hermes-auditd/main.go: removed the misleading “HTTP/WebSocket server not implemented — notify channel discarded” warning; the notify channel is now drained intentionally (data is read back via hermes-top).
  • watcher.go: added Close() method to fix fsnotify file descriptor leak
  • flake.nix: fixed mkHermesAgent import (was attrset, now unwrapped function)
  • watcher.go: fixed FlushAll timer race with entry.timer = nil guard after Stop()
  • CI: added go test step to GitHub Actions workflow
  • installer.sh: removed dead spinner() function
  • store.go: removed unused done channel
  • Docs: fixed nftables attribution (hardening.nixnetworking.nix)
  • Initial release: NixOS flake template for multi-agent Hermes orchestration
  • lib/mkHermesAgent.nix — declarative Docker-based Hermes agent creation
  • lib/constants.nix — template-level defaults (stateVersion, locale, hostname)
  • modules/ — reusable NixOS modules (boot, locale, networking, users, hardening, tailscale, etc.)
  • pkgs/hermes-auditd/ — Go daemon for filesystem event auditing with SQLite
  • installer/ — interactive TUI installer ISO (nix build .#installer-iso)
  • docs/ — quickstart guide, agent tips, skill index, and 4 bundled Hermes skills
  • GitHub Actions CI: nix flake check on PR and push to main