Skip to content

v2.0a1

Pre-release
Pre-release

Choose a tag to compare

@mcdonc mcdonc released this 08 Sep 06:44
· 37 commits to main since this release

Breaking

  • KLANGKD_LISTEN / KLANGKD_EGRESS_LISTEN values are validated
    (#3275).
    Both settings now fail startup (and deny a SIGHUP reload)
    with the setting named unless the value is a bare bind address — an
    IPv4/IPv6 literal or a host name, stored stripped + lowercased (a
    bracketed IPv6 literal normalizes to the bare form; a trailing DNS
    root dot is dropped). A port inside the address (127.0.0.1:8080)
    used to boot while silently binding listen:KLANGKD_PORT — move the
    port to KLANGKD_PORT / KLANGKD_EGRESS_PORT on upgrade; CIDR and
    newline values used to wedge the proxy watchdog in an endless
    kill/respawn loop or inject Caddyfile directives. The proxy watchdog
    now also aborts instead of respawning when Caddy rejects the config
    because a configured listener address cannot be bound (unparsed,
    unresolvable, non-local, or already taken) — e.g. an interface name
    like eth0, which is a syntactically valid host name that passes
    validation.

  • Host-header validation changes URL derivation for name-accessed
    deployments (#3276).
    A request's Host header now validates against
    klangkd's own configuration (loopback, the armed KLANGKD_TLS_HOSTNAME
    name, or the KLANGKD_LISTEN IP-literal address) before it may name
    the authority of any URL klangkd builds — reset/verification emails,
    invitations, the OIDC redirect, hosted-app URLs; every other value
    falls back to localhost. Migration: deployments reached by an
    identity klangkd does not configure (KLANGKD_LISTEN=0.0.0.0 by DNS
    name or non-literal address, a hostname listen, an outer proxy not
    in KLANGKD_TRUSTED_PROXY_CIDRS) must set KLANGKD_HOSTING_HOSTNAME
    or their links, container hosted-app URLs, and the OIDC redirect_uri
    (login fails on the mismatch) all name localhost. Loopback,
    automatic-TLS, and trusted-outer-proxy deployments are unaffected. See
    HTTPS Hosting.

  • Token-in-URL removal changes client contracts (#3201).
    Anything connecting to /ws or /ws/consent-decider must switch
    from the ?token= query param to the bearer WebSocket subprotocol
    (the JWT rides the handshake's Sec-WebSocket-Protocol header), and
    any client of the verify endpoint must POST the token in the body
    (the GET form is gone). Verification/reset emails sent by a
    pre-upgrade server stop working after the upgrade (the new one-time
    bindings reject the old token shapes) — affected users must request
    a fresh link. See the token delivery policy.

  • User bind mounts disabled by default (#3153). With
    KLANGKD_ALLOWED_MOUNT_ROOTS unset (previously: any non-protected
    host path was allowed), workspace mount entries with a host-path
    source are now rejected at create/edit — only named volumes may be
    mounted. Set KLANGKD_ALLOWED_MOUNT_ROOTS to a comma-separated
    list of roots to allow bind mounts under them. klangkd's own
    mounts (workspace home, config, SSL trust, per-workspace nix) are
    internal and unaffected.

  • Volume ownership + quota rename (#3153). Named volumes are
    workspace-owned and cannot be shared across workspaces; volumes
    created before this change (user-id labeled, no workspace label) are
    no longer mountable — remove and recreate their mounts. The
    per-user quota KLANGKD_VOLUME_QUOTA_PER_USER is now the
    per-workspace KLANGKD_VOLUME_QUOTA_PER_WORKSPACE
    (volume_quota_per_workspace).

  • Members can create workspaces by default (#3137). The seeded
    create-workspace Allow on /workspaces now targets the members
    group (which every new user joins) in addition to admins, so a
    stock multi-user deploy is self-service out of the box — bounded by
    the admission/quota controls (KLANGKD_ADMISSION_*,
    max_running_workspaces_per_user, volume_quota_per_user) and
    allowed_images / allowed_mount_roots. On upgrade, migration
    m0029 appends the grant after any existing /workspaces rows
    , so
    existing deployments flip too; a deploy that wants the old
    admin-only posture adds one explicit Deny for members (or
    Authenticated) ahead of the Allow in the ACL editor. See
    ACL System.

  • KLANGKD_PER_HANDLE_HOME is now a ceiling, not a default (#3135).
    The flag no longer just pre-selects the home layout for new
    workspaces — it gates whether per-handle homes are permitted at all
    (the same shape KLANGKD_ALLOW_SUDO got in #3047). While it is
    false (the default), every workspace gets the shared
    /home/klangk regardless of its stored per_handle_home value: a
    stored true (including the population migration 0009 backfilled
    for pre-feature workspaces) is inert, clamped at the next
    connect/start and never rewritten — no create/edit request is
    rejected. Operators whose workspaces rely on per-handle homes must
    set KLANGKD_PER_HANDLE_HOME=true on upgrade. With the ceiling on,
    behavior is unchanged (workspaces choose either layout; an omitted
    create field stores the flag's value). The Per-handle home toggle
    is hidden in the web dialog/settings panel and the TUI forms while
    the ceiling is off (per_handle_home_available on /config), and
    the CLI --per-handle-home flag cannot raise a workspace past the
    ceiling. Reloadable on SIGHUP; applies to containers started after
    the change. See
    Workspaces.

  • Per-workspace sudo is now opt-in (#3046, #3047). KLANGKD_ALLOW_SUDO
    no longer grants passwordless sudo by itself — it is now only the
    ceiling that permits a workspace to opt in. Sudo is on for a workspace
    only when its settings bag stores allow_sudo: true (the Allow sudo
    toggle, which now defaults to unchecked in the Flutter dialog/settings
    panel and the TUI form, or klangk create --sudo / --sudo on edit);
    an absent key means locked down, and a true can never raise sudo
    past KLANGKD_ALLOW_SUDO=0. On upgrade, existing workspaces with
    no stored allow_sudo key lose sudo at their next container start

    (no bag migration runs) — opt them in with klangk edit <ws> --sudo
    if they need it. See Container
    packages
    .

  • Volumes are an admin surface (#2993). GET /volumes now checks
    the new view-volumes permission (the admin Volumes tab's listing
    gate), while POST /volumes and DELETE /volumes/{name} keep
    manage-volumes — both seeded Allow for the admins group only,
    so non-admin users lose volume list/create/delete access on
    upgrade (migration m0026 replaces the old rows; custom operator
    rows that don't match the seeded shapes survive below the new admin
    rows — re-grant via the ACL editor if a deploy wants self-service
    volumes back). The tab lists and deletes volumes (delete needs
    manage-volumes); there is no create surface, and
    manage-volumes holders may delete any instance volume. The
    listing now returns the whole inventory — creator provenance,
    using-workspace names, search, and paging — in a
    {volumes, page, page_size, total} envelope documented in
    docs/reference/api-endpoints.md; klangk volumes ls ships with
    it, but external API consumers must read the envelope.

  • Deploy-wide consent decider removed (#2976). The
    /ws/consent-decider handshake without a ?workspace= param is now
    refused (HTTP 403): consent authority is strictly per-workspace
    (egress-consent on /workspaces/{id}), and manage-server-schedule
    no longer authorizes any consent path. Operators who relied on a
    standing admin decider covering every interactive workspace lose that
    override: a workspace with no connected member decider now reverts to
    its static allow-list. The klangk shell popup decider and the web
    workspace page both register a decider whenever the member holds
    egress-consent, so interactive workspaces keep working for their own
    members.

  • Deploy capability toggles moved off the images listing (#2994).
    GET /api/v1/images no longer returns nix_available /
    sudo_available — read them from the new authenticated-only
    GET /api/v1/config fields of the same names. Hand-built clients
    reading the toggles off the images response must switch to /config.
    Migration 0025 also removes the retired seed's Deny Everyone row on
    /images (it gated no route): authenticated users' effective
    permissions on /images now include the view inherited from /,
    visible in /my-permissions — informational only.

  • Workspace-sphere permission names (#2946). Every stored ACE and
    every client that checks a workspace permission must use the new
    specific names: create-workspace (on /workspaces),
    edit-workspace, delete-workspace, duplicate-workspace,
    transfer-workspace (replaces admin on the workspace),
    monitor-workspace, export-workspace, share-workspace,
    share-advanced (replaces change-acls), and files-view
    (replaces files). Migration m0022 renames the stored ACE rows
    automatically, including per-workspace role groups; view,
    terminal, files-download, files-write, and the egress/shared-
    terminal names are unchanged. Scripts and hand-built clients that
    check or grant the old names must rename them the same way.
    Lifecycle control also splits out of terminal: start-workspace,
    stop-workspace, and restart-workspace are now checked on their
    own. m0022 grants the trio to every existing workspace's
    coders-*/collaborators-* role groups (matching the fresh seeds);
    spectators no longer hold lifecycle control — re-grant the trio
    manually if a spectator group should keep it.

  • Self-service surfaces are permission-gated (#2946). The volumes
    API and the images listing now check manage-volumes and
    view-images on their own resources — seeded Allow for
    Authenticated (m0023 seeds existing deployments), so default
    behavior is unchanged; a deploy can now deny them per user/group
    via the ACL editor. GET /users/search (member-picker type-ahead)
    checks search-users on /users, also Allow Authenticated by
    default. The LLM proxy is gated separately by its own
    workspace-token requirement (#2959).

  • /admin/* API paths moved to first-class resources (#2944).
    Scripts and clients calling the old /api/v1/admin/users*,
    /api/v1/admin/groups*, /api/v1/admin/invitations*,
    /api/v1/admin/schedule*, /api/v1/admin/events, or
    /api/v1/admin/acl* paths must switch to /api/v1/users*,
    /api/v1/groups*, /api/v1/invitations*, /api/v1/server/schedule*,
    /api/v1/events, and /api/v1/acl/* respectively (the old paths
    404). The CLI, web frontend, e2e suites, and seeds are already
    migrated. The /admin * wildcard no longer covers these surfaces —
    the walk from the new resources never passes through /admin — so
    delegations granted on the old /admin/users, /admin/groups, …
    sub-resources match nothing anymore: re-grant the manage-*
    permission on the new first-class resource. The pre-existing
    /groups Allow create seed is migrated automatically (m0021); any
    other custom /groups rows are left for a manual re-grant.

  • Hand-crafted admin ACEs stop matching split routes (#2940). ACLs
    granting the literal admin permission on /admin (rather than the
    seeded * wildcard) no longer satisfy the per-tab endpoints — grant
    the tab permission (or *) instead. Default deployments are
    unaffected. If you granted container-events ACEs while running
    main, rename them to manage-events (the feature was never in a
    release). Likewise, pre-#2940 /groups Allow create delegations
    are now inert: group creation is gated by manage-groups on
    /admin/groups, and that permission covers the whole Groups tab
    (edit, delete, member management) — re-grant accordingly.

  • The seeded admin group is renamed to admins (#2934). Fresh
    installs seed a group named admins; upgrading renames the admin
    group in place (memberships and ACLs keep pointing at the same
    group). If a group named admins already exists (created manually),
    boot fails: stop klangkd, rename that group directly in SQLite
    (UPDATE groups SET name = 'admins-manual' WHERE name = 'admins';
    on <data-dir>/klangk.db), and restart. OIDC login hooks must
    return "admins" from on_login before the first post-upgrade
    login
    — an unchanged hook auto-creates a permissionless admin
    group and its membership diff-sync strips the user's synced admins
    membership at their next login.

  • enable_ping is removed; workspaces never hold CAP_NET_RAW (#2347).
    The KLANGKD_ENABLE_PING setting is gone (ignored on an existing config)
    and every newly created workspace container launches with
    --cap-drop net_raw; unprivileged ping inside a workspace no longer
    works (the setuid-ping / ping_group_range / setcap alternatives all
    fail under rootless podman, #2045). Applies to containers started after
    the upgrade; rebuild the workspace image to also shed the now-useless
    setuid ping binary baked into older ones.

  • GET /api/v1/groups now returns a paged envelope (#2750). The
    response is {groups, page, page_size, total} (same shape as
    GET /api/v1/admin/groups) instead of a bare list that was silently
    truncated at 200 rows. Integrators must read the groups key and
    paginate with page/page_size.

  • KLANGKD_ALLOW_SUDO now defaults to on (#2017). Passwordless sudo
    inside workspace containers is granted by default; operators who want
    the previous locked-down posture must set KLANGKD_ALLOW_SUDO=0
    explicitly. The per-workspace lock-down (allow_sudo: false in the
    workspace settings bag, klangk create/edit --no-sudo, or the UI
    toggle) can still opt individual workspaces out. Applies to containers
    started after the change.

  • The work/ subtree is removed from workspace homes (#2725). The
    separate shared project directory /home/work no longer exists:
    project files live directly in the klangk user's home (/home/klangk
    or /home/<handle> under the per-handle layout), which is the working
    directory for shells, exec sessions, and the image's WORKDIR. There
    is no data migration: existing workspaces keep their files in
    ~/work — move them up manually (mv ~/work/* ~) if you want them at
    the top of the home. Importing an old export archive (with home/work/)
    preserves its layout as-is.

  • The default home layout is now shared (#2723). KLANGKD_PER_HANDLE_HOME
    (and the per_handle_home setting) now defaults to false: new workspaces
    share one /home/klangk instead of per-user homes. Existing workspaces are
    unaffected (migration backfilled true). Set
    KLANGKD_PER_HANDLE_HOME=true — or pass the per-workspace create flag — to
    keep per-handle homes.

  • The chat feature is removed (#2716). The per-workspace chat panel, the
    @klangk agent interaction, the pi --mode rpc chat-agent runtime, and the
    chat feature flag are gone. KLANGKD_FEATURES_ENABLE=chat on an existing
    config is ignored with a startup warning — no operator action required.
    Existing chat_messages/chat_mentions tables and stale chat ACL rows
    remain in the DB as inert leftovers (no destructive migration). The chat
    workspace permission is removed from the known-permissions list and the
    sharing UI; the agent user identity (DB row, handle, inactivity-sweep
    exemption) and its ownership of the service tmux session are unchanged.
    The agent home is still materialized at container create — now a plain
    /home/klangk directory (no .users/{uid} symlink indirection; the
    handle is fixed) populated from /etc/skel, without chat-agent Pi
    config. klangk-setup-pi stays as the generic per-user Pi setup.

  • The agent user is klangk (#2718). The agent's identity is fixed
    (handle klangk, email klangk@example.com) and matches the container
    UNIX user / shared home. klangk is a reserved handle and the agent
    row's handle/email can no longer be changed. The
    KLANGKWS_FEATURE_CHAT_AGENT_HANDLE/EMAIL feature-config keys are
    removed (stale settings are ignored). Migration m0008 rewrites the
    agent row and relocates a human user who already held the klangk
    handle to a unique alternative. Deployments that customized the agent's
    name lose that customization.

  • Interactive workspaces now require the network sidecar (#2325). Every
    egress_mode=interactive workspace (the default) spawns a network sidecar
    and holds each new outbound host for a consent decision. On upgrade, an
    existing interactive workspace's next start requires a configured
    network_sidecar_image and a non-empty KLANGKD_USERNS; if either is
    missing it fails closed (refuses to start) instead of egressing
    unrestricted. An interactive workspace with allow_sudo also had net_raw
    dropped (defense-in-depth against the SO_MARK bypass) — since #2347 the
    drop applies to every workspace. Static workspaces with no allow/reject
    lists are otherwise unaffected.

  • (#1653) Environment variables renamed to KLANGKD_* / KLANGKBUILD_* /
    KLANGKWS_* / KLANGK_*. Old KLANGK_* names are not accepted. Update
    deploy manifests.

  • "Plugin" → "feature" rename (#1658). plugins.yamlfeatures.yaml,
    plugins/features/, update-pluginsupdate-features, etc.

  • Feature config keys must start with KLANGKWS_FEATURE_ (#1662). Rename
    KLANGKD_GITHUB_OAUTH_CLIENT_IDKLANGKWS_FEATURE_GITHUB_OAUTH_CLIENT_ID,
    KLANGKBUILD_BOING_SPEEDKLANGKWS_FEATURE_BOING_SPEED, etc.

  • KLANGKD_CUSTOMIZE_DIR moved to <config_dir>/custom (#1644). Move
    contents from <state_dir>/custom or set KLANGKD_CUSTOMIZE_DIR explicitly.

  • One klangk distribution (#1606). pip install klangk yields both
    klangkd (server) and klangk (client). import klangk_backend
    import klangkd. The klangkc PyPI distribution is retired.

  • Default auth mode is none (#1374). Set KLANGKD_AUTH_MODES=password
    explicitly if you relied on the old default. none is loopback-bound and
    safe by construction.

  • OIDC settings no longer change the auth mode (#1419). Set
    KLANGKD_AUTH_MODES=oidc (or both) explicitly.

  • klangk inviteklangk admin invitations send (#1374).

Security

  • DPoP proof freshness and replay hardening (#3272). A proof
    whose iat is not a usable timestamp — NaN/Infinity literals
    decoded via JSON, a boolean, or an integer too large for a float —
    is now rejected as stale; a NaN-dated proof previously verified at
    any time and could be replayed indefinitely, and an oversized
    integer crashed verification with an unhandled error. A proof's
    replay-cache entry now lives until the last moment the proof can
    still pass the freshness check, closing a doubled replay window for
    future-dated proofs.

  • GET /groups no longer discloses workspace names (#3283).
    Workspace role groups are seeded with an id-only description
    (Workspace role group: role; the group name already carries the
    workspace id), and a migration rewrites existing rows — a
    description no longer embeds the free-form workspace name. The
    default group listing (no source parameter) returns
    source='workspace-role' rows only to callers holding
    manage-groups; other authenticated callers get the manual-only
    view. Explicit source=manual and source=workspace-role filters
    work as before for every authenticated caller.

  • Host-header poisoning of reset/verify links and the OIDC redirect
    (#3276).
    A request's Host (and the X-Forwarded-Host/-Prefix
    the managed Caddy would derive from it for untrusted peers) can no
    longer name the authority of URLs klangkd builds — one direct request
    with a forged Host can no longer steer the emailed reset link at an
    attacker (silent account takeover). Name-accessed deployments must set
    KLANGKD_HOSTING_HOSTNAME; see the Breaking entry.

  • Workspace import decompression bounds (#3284). The import
    endpoint bounds the archive's decompressed payload, not just the
    compressed upload: a workspace.json member beyond 1 MB is rejected
    with 413 (previously it accumulated unbounded in memory — a small
    crafted archive could OOM klangkd), and the home/ tree is
    pre-scanned and rejected with 413 when it would exceed the free
    space on the workspace volume (2 GB kept in reserve) or the new
    import_max_uncompressed_mb cap (KLANGKD_IMPORT_MAX_UNCOMPRESSED_MB).
    Large legitimate imports succeed whenever the volume has room.

  • Bind-mount re-validation at container start (#3278). A mount's
    host-path source is now checked against
    KLANGKD_ALLOWED_MOUNT_ROOTS and the protected-path blocklist every
    time a workspace container is created, not only when the mount is
    saved. A source that changed after it was saved (for example, a
    directory under an allowed root replaced by a symlink pointing
    elsewhere) — or a mount row that reached the database without the
    settings gate — is refused with a clear start error instead of
    mounting an arbitrary host path into the container. Mount specs
    containing a NUL byte (in the source or the destination) are also
    refused at both gates; a saved bind mount on a deploy with no
    allowed roots configured now fails at start — configure the roots
    or remove the mount. See
    Mount security.

  • GET /users/search matches filter text literally (#3280).
    % and _ in the q parameter are now treated as literal
    characters (escaped LIKE patterns), so a one-character wildcard
    query can no longer enumerate the full user directory — the
    type-ahead again requires a real prefix. Every admin list filter
    (users, groups, invitations, workspace names) and the event-history
    filters (workspace / actor / target / event) match filter
    text literally the same way.

  • Tokens removed from URLs (#3201). Session JWTs no longer ride
    URLs anywhere. WebSocket clients (browser and CLI) now authenticate
    the handshake via the Sec-WebSocket-Protocol header instead of a
    ?token= query string; the OIDC callback redirects a one-time,
    60-second code redeemed via POST /api/v1/auth/oidc/exchange; and
    POST /api/v1/auth/verify (formerly GET) takes the verification
    token in the body. Email-link tokens (verify/reset/invite) are now
    strictly one-time — a replayed verification link or a reset link
    minted before an earlier reset is rejected. Client-contract
    migration notes: see the Breaking entry above.

  • KLANGKD_SESSION_WORKSTATION_BINDING (#3194). Session workstation
    binding: replay protection for bearer JWTs. off (the default)
    keeps the previous behavior; ip binds each session to the network
    it was established from (two IPv6 addresses inside one /64 count as
    the same) and strict also requires the same User-Agent. A token
    presented from a different workstation — HTTP request, refresh, or
    WebSocket connect — is rejected (401 / close 4001), its session
    revoked, and the violation audited (log line plus a
    session.revoke structured audit event, #3205), logging the
    legitimate client out too. Sessions with an unknown recorded IP are
    never rejected; reloadable on SIGHUP. See
    Authentication: session workstation binding.

  • DPoP token binding (#3218). Web sessions bind their JWT to a
    non-extractable WebCrypto ECDSA P-256 key held in IndexedDB: the
    token carries cnf.jkt, and every authenticated request and
    WebSocket connect must present a fresh proof signed by the private
    half. A stolen bound token is useless without the key, and the key
    cannot be read by any script — the stored-token-theft exposure of
    V-222575/V-222576 shrinks to the brief mint-to-bind window and to
    sessions that never bind (binding is best-effort at the browser).
    CLI and TUI clients are unchanged (their tokens stay unbound); a
    bound token whose key is gone forces a re-login. Requires a secure
    context (HTTPS or localhost); plain-HTTP remote deployments keep
    the previous unbound behavior. See
    Authentication: DPoP session-token binding.

  • KLANGKD_WEB_BIND_GRACE_SECONDS (#3230). Sessions minted for
    the web client are now born DPoP-bound: the SPA's minting
    requests carry its public binding JWK (Klangk-Binding-Jwk; an
    OIDC login URL carries it into the state cookie, a key-less web
    build rides an explicit none, and a web flow arriving without it
    is refused at the callback before any exchange), so there is no
    unbound window to read, sabotage, or bind-first with a substituted
    key. Every WebSocket — main and consent-decider alike — is armed to
    close at its token's expiry and re-armed on rotation. This setting
    is the deadline backstop for tokens that nonetheless mint unbound:
    still unbound past it (default 300 seconds; 0 disables), the
    session is refused with 401 everywhere until re-login. CLI/TUI
    sessions are unmarked and unaffected. See
    Authentication: DPoP session-token binding.

  • CSP: hash-allowed inline scripts + Trusted Types (#3219). The
    served Content-Security-Policy drops 'unsafe-inline' from
    script-src: the inline <script> blocks of the built frontend's
    index.html are allowed by SHA-256 hash tokens computed at
    config-render time, and require-trusted-types-for 'script' now
    blocks DOM-based XSS sinks (such as innerHTML); a minimal default
    Trusted Types policy in index.html sanctions only relative,
    same-origin, and same-origin blob: script URLs, keeping the PDF
    viewer's wasm loader working (the build also swaps the viewer's
    importScripts-based blob worker for an inlined same-origin worker
    file — importScripts is a Trusted Types sink inside the worker).
    Styles keep 'unsafe-inline' because
    Flutter injects runtime styles. After a frontend rebuild that changes
    index.html's inline scripts, a settings reload (SIGHUP) or restart
    re-computes the hashes.

  • Session token storage (#3193). The frontend now keeps the session
    JWT in browser sessionStorage instead of localStorage, so closing
    the tab or the browser ends the session instead of leaving a usable
    token on disk. A token persisted by an older build is migrated on the
    next load and removed from localStorage. Signing in again is needed
    after a browser restart, a session-restore reopen, or opening the app
    in a new tab.

  • KLANGKD_STEP_UP_WINDOW_MINUTES (#3196). Step-up (sudo-mode)
    reauthentication for privileged writes: user/group/invitation
    management, admin ACL rewrites, server schedules, volume deletes,
    and takeover-class writes on workspaces you do not own (delete,
    raw ACL rewrite, ownership transfer, role assignments) are refused
    with a
    machine-readable 403 step_up_required until the session's owner
    confirms their password via POST /auth/step-up; the confirmation
    is per session (survives token refresh, dies with logout), has
    login-grade lockout accounting, and is honored for the window;
    refusals, confirmations, failed checks, and OIDC exemptions land in
    the structured audit log (step_up.* events).
    0 (the default) disables the gate; 15 is the recommended value.
    OIDC-managed accounts are exempt (audit-logged); the web client and
    CLI prompt and retry automatically. Reloadable on SIGHUP. See
    Authentication.

  • Consent-decider sockets are closed on token revocation (#3162).
    The /ws/consent-decider connection now shares the #3152 revocation
    story: logging out, being evicted by the per-user session limit, or
    having the account disabled (admin action or the inactivity sweep)
    immediately closes the decider sockets that credential authenticated
    (close code 4001) and drops their registrations, so egress-consent
    authority (verdicts, revokes, pause) ends with the credential.
    Previously the decider socket — which lives in its own registry —
    kept that authority indefinitely after logout or disable. Refresh
    rotation retargets the decider onto the new token instead of closing
    it.

  • WebSocket connections are closed on token revocation (#3152).
    Logging out, or being evicted by the per-user session limit, now
    immediately closes the live WebSocket connections the revoked token
    authenticated (close code 4001, so clients log out instead of
    reconnect-looping). Previously an established socket kept full
    data-plane access until the next reconnect. Refresh rotation is
    unaffected — a refreshed session keeps its socket, retargeted onto
    the new token so a later revocation still closes it.

  • WebSocket connections close on token expiry (#3152). A socket
    now schedules its own close (code 4002) at the access token's exp
    time. Previously a socket established before the token expired would
    stay open indefinitely until the client happened to reconnect.

  • Password change revokes all sessions (#3152). POST /api/v1/auth/change-password now blocklists every active session
    token and closes their WebSocket connections, forcing re-login on
    all devices.

  • Content-Security-Policy on the served frontend (#3149). klangkd's
    rendered Caddy config now sends a strict first-party CSP (plus
    X-Frame-Options: DENY) on the browser listener's frontend responses;
    API, WebSocket, and hosted-port paths are excluded. To ship an eval-free,
    fully first-party policy, boingball left the default feature set and the
    beep/boingball features plus the file viewer's monospace font were
    made first-party: Web Audio calls use typed interop instead of JS eval,
    and Roboto Mono is self-hosted instead of fetched from fonts.gstatic.com.
    Unpinned deployments lose the boingball overlay on upgrade (it left the
    defaults list); keep it by pinning KLANGKD_FEATURES_ENABLE to a list
    that includes boingball.

  • HMAC integrity tags for audit records (#3174).
    With KLANGKD_AUDIT_HMAC_KEY set, every container_events and
    egress_consent row carries an HMAC-SHA256 tag computed at insert
    time (and re-computed on decide/revoke/expire mutations). When
    unset (the default), no tag is written — there is deliberately no
    derivation from KLANGKD_JWT_SECRET. klangkd only writes tags;
    verification (re-computing tags against the key) is left to external
    consumers such as off-host audit backups. Rotating the key
    invalidates tags written under the old key.

  • Fail to a secure state on shutdown/abort failure (#3176). The
    shutdown teardown is hardened so a failure in one step never skips
    the rest (proxy child, containers, DB dispose all run). A drain
    that fails or under-stops during SIGTERM/SIGINT now triggers a
    verified forced backstop (CRITICAL names any leftover containers),
    and a failed SIGHUP recovery exits with status 1 after the graceful
    teardown so Restart=on-failure supervisors restart the node.

  • FIPS mode now keeps JWT signing inside the validated module
    (#3175).
    The FIPS host image rebuilds cryptography from source
    against the distro OpenSSL (the PyPI wheel bundles a private OpenSSL
    that bypasses the FIPS provider), and KLANGKD_FIPS_MODE verifies at
    startup that python-jose binds the cryptography backend and that
    its OpenSSL is the process's own provider-gated library. The FIPS
    docs now include the complete cryptographic inventory and the
    V-222555 posture rationale.

  • Bounded rate-limit state for the email cooldowns (#3113). The
    per-address cooldown dicts behind
    POST /api/v1/auth/forgot-password and
    POST /api/v1/auth/resend-verification are now capped at 10,000
    hashed keys, shed oldest-first, and sweep expired entries only when
    recording. An unauthenticated flood of unique addresses can no longer
    grow process memory or per-request CPU without bound, and no raw
    email strings are retained.

  • Forgot-password no longer leaks account existence via SMTP
    failures or response timing (#3114).
    POST /api/v1/auth/forgot-password now answers "sent" immediately and
    delivers the email in a background task, logging failures
    server-side. Previously a broken SMTP backend answered 503 for
    existing enabled accounts but 200 for unknown/disabled ones, and the
    inline SMTP round-trip made the existing-enabled path measurably
    slower — both usable as account-existence oracles. Operators should
    watch the server log for reset-email delivery failures instead of
    relying on the HTTP response.

  • Forgot-password rate limiter no longer leaks account existence
    (#3100).
    The 60-second per-address cooldown on
    POST /api/v1/auth/forgot-password now applies before the account
    lookup, so unknown, disabled, and enabled addresses all answer 429
    identically on a repeated request. Previously only existing enabled
    accounts could be rate-limited, making the cooldown an oracle for
    both account existence and the disabled state.

  • Per-frame gates on the own-terminal and ssh-agent WS commands
    (#3022).
    With join-workspace as the connect gate (#2975), a
    join-only member could reach frames whose only protection was the
    old terminal-checked handshake: ssh_agent_start spawned a socat
    relay in the container, and the own-window frames
    (terminal_new_window/select_window/close_window/
    rename_window/list_windows) ran tmux against the caller's
    session — which, for a spectator viewing a shared terminal, is a
    grouped session whose windows belong to the whole group (they could
    inject or close the owner's windows). All six frames now refuse
    with a plain Permission denied error frame (deliberately not the
    forbidden code, which #2891 reserves for connect-level refusals —
    a stamped sub-action denial would dead-end the whole workspace
    page) unless the caller holds code-in-isolation (window frames)
    or either code-in-isolation or exec-and-sync (the agent relay,
    which both session kinds consume). Seeded roles are unaffected:
    every role whose clients send these frames already holds the
    permissions, and the web UI already hid them.

  • Workspace-mount volume-source validation (#3018). A mount
    source with no / that doesn't start with . is a named volume,
    and must now be podman-safe to pass workspace create/update mount
    validation (alphanumeric first character, a-zA-Z0-9_.- only, at
    most 64 characters — the same rule as the volumes API, #2971),
    returning HTTP 400 on violation. Previously such a source reached
    podman volume create/inspect argv verbatim at container start, so
    a leading-dash source was parsed as a podman flag; the check also
    runs at start as defense in depth for workspaces created before the
    gate.

  • Volume name validation (#2971). POST /api/v1/volumes and
    DELETE /api/v1/volumes/{name} now reject names that are not
    podman-safe — they must start with an alphanumeric character,
    continue with a-zA-Z0-9_.- only, and be at most 64 characters —
    with HTTP 422. Previously any string was appended verbatim to the
    podman command line, so a leading-dash name was parsed as a podman
    flag (on delete, --all could remove every unused volume on the
    host).

  • Volume-create conflict check no longer leaks foreign volume names
    (#2973).
    POST /api/v1/volumes answered 409 for any podman volume
    name that existed on the host, letting a user probe for volumes the
    Klangk instance doesn't manage (other instances', operator-created).
    The 409 is now returned only for volumes labeled with this instance's
    id; other names fall through to the create, which fails podman-side —
    the client sees a bare internal error with no probed name, and
    podman's conflict text reaches only the server log.

  • /llm-proxy endpoints now require a workspace JWT (#2959). The
    backend validates the workspace token itself, mirroring the egress
    proxy's forward_auth check. Previously the backend routes were
    unauthenticated, so the proxy (and its upstream API keys) was usable
    from outside a workspace container by any client that could reach the
    backend directly — including anonymously through the browser
    listener's /llm-proxy/ pass-through. User login tokens are rejected;
    the proxy is usable only from inside workspace containers.

  • Image builds verify third-party inputs (#2063). Base images (workspace
    base, python host, Alpine sidecar, Debian FIPS builders + nix-seed sandbox)
    are now pulled by immutable @sha256: digest, with the base-image workflow's
    auto-PR pinning the digest. The uv and process-compose release tarballs are
    SHA-256-verified per architecture before extraction (no more curl | sh /
    curl | tar pipes), the Pi agent npm tarball is fetched directly and
    SHA-512-verified, and the NodeSource / GitHub CLI / Caddy apt repo keys are
    hash-verified before entering a keyring (Caddy's sources list is written
    inline). Pins live in the Dockerfiles; rotation procedures and known residuals
    are documented in Building Images.

  • Browser-delegate requests are bound to the caller's workspace
    (#1715).
    /api/v1/browser-delegate and /api/v1/browser-delegate/stream
    now verify that the submitted browser_id was registered against the
    same workspace as the caller's workspace token, and return 403
    otherwise. Previously a container holding workspace A's token could
    relay actions (e.g. git-credential prompts, browser fetch) to another
    workspace's browser tab if it learned that tab's browser ID — the
    token provided no workspace boundary on the relay.

  • New monitor permission gates health/status reception (#2783,
    #1714).
    Observing a workspace's health no longer requires
    terminal: GET /workspaces/{id}/status and the member-scoped
    container_status / service_health / workspace_evicted
    WebSocket frames now check the dedicated monitor permission.
    Every role and share that grants terminal also grants monitor
    (existing deployments are backfilled by migration 0016), and
    monitor can be granted alone for monitoring-only members who
    should observe health without exec/attach access.

  • New egress-consent permission gates egress decisions (#2883).
    Registering a consent decider (the web Network tab, the consent
    banner, klangk consent-decide), deciding held requests, revoking
    verdicts, and pausing prompting now require egress-consent instead
    of terminal — a spectator (watch-only) can no longer decide a
    workspace's egress, and the Network tab and consent banner no longer
    render for members without the permission. Owners, coders, and
    collaborators hold it by default (existing deployments are backfilled
    by migration 0018); pause/unpause no longer additionally require
    share-terminals. Members or groups granted only terminal — whether
    via a custom ACL or the simple Sharing tab (its grants do not include
    egress-consent) — must be granted egress-consent explicitly to
    keep deciding; grant it in the Advanced ACL editor. See
    ACLs.

  • Workspace status WebSocket broadcasts are now scoped to workspace
    members (#1714).
    container_status, service_health (including
    the connect-time snapshot), and workspace_evicted frames were
    fanned out to every authenticated connection, letting any connected
    client enumerate every workspace's id, running state, health, and
    the bounded health_message tail of another tenant's service
    output; they are now delivered only to users holding monitor on
    the workspace. A view-only grantee still sees status in the
    workspace list (HTTP), but receives no live deltas; an admin
    watching other tenants' workspaces via klangk monitor now sees
    only their own workspaces — intended, and part of the fix.

  • exec-and-sync permission gates one-shot command execution and
    klangk sync (#2706, #2712).
    The one-shot exec channel — klangk exec, and the rsync transport klangk sync and klangk sandbox
    ride on — now requires the new exec-and-sync permission on the
    workspace, enforced server-side at exec_start. Both sync directions
    are covered by the same gate: a member without exec-and-sync cannot
    run one-shot commands or sync in either direction. Isolated terminals
    still use code-in-isolation and are unaffected. Coders and
    collaborators keep exec-and-sync (existing workspaces are backfilled
    by migration), so revoking it is an admin choice — remove the
    permission in the ACL editor to stop one-shot exec and bulk sync for a
    member while keeping their terminal access. Custom ACEs that granted
    code-in-isolation must add exec-and-sync explicitly to keep
    one-shot exec working for those principals. klangk exec/klangk sync report a clear permission-denied error.

  • Terminal sharing verified permission-gated (#2709). As part of
    the #2589 exfiltration-avenue audit, terminal sharing was verified to
    be permission-gated end-to-end (long-standing behavior, unchanged
    this release): share/unshare requires share-terminals (owners and
    collaborators by default) and joining another member's shared
    terminal requires spectate-on-shared-terminals (all roles); a
    joiner without code-in-shared-terminals or share-terminals
    joins read-only. The browser hides the Share context-menu action for
    members lacking the permission, the server rejects the commands, and
    private, unshared terminals are unaffected. Revoke the permissions
    per workspace in the ACL editor to stop a member sharing their own
    tabs or watching others'.

  • Workspace export is gated on the workspace, not admin (#2707).
    GET /api/v1/workspaces/{id}/export (and klangk export) now
    requires the export permission on /workspaces/{id} instead of the
    admin permission on /admin: owners keep exporting their own
    workspaces (the owner wildcard ACE and the seeded owners-<id> role
    group both cover export), while admins no longer bulk-export
    workspaces they hold no grant on. A Deny export ACE on a workspace
    resource, positioned ahead of the wildcard allows, revokes export per
    workspace (rewritable by anyone holding share there). See
    Export & Import.

  • Group creation restricted to administrators (#2770). The default
    ACL no longer grants create on /groups to every authenticated
    user; it goes to the admin group instead, matching workspace
    creation (#2569). Existing deployments whose /groups still carries
    exactly the seeded ACE are migrated automatically; if your /groups
    entries differ (deliberately loosened or customized), remove the
    Allow create → Authenticated users entry via the ACL editor. To
    re-open group creation, add an Allow ACE for create on /groups
    targeting the members group.

  • Workspace creation restricted to administrators (#2569). POST /workspaces, POST /workspaces/import, and workspace duplication
    now require the create permission on the /workspaces collection
    resource, which defaults to the admin group only. Non-admin users
    see the create button hidden and receive 403 if they call the API
    directly. A new built-in members group is seeded at startup and
    every new user (registration, invitation, OIDC, admin-created) is
    added to it automatically. To let all members create workspaces, add
    an Allow ACE for create on /workspaces targeting the members
    group via the ACL editor.

  • FIPS host container image + containerized boot gate (#2628). New
    src/containers/host/Dockerfile.fips layers the validated OpenSSL
    FIPS provider onto the docker host image (klangkd's own PBKDF2
    password hashing, JWT HMAC-SHA256, and outbound TLS then run inside
    the validated boundary) and embeds the FIPS workspace image in place
    of the stock one; build with klangk:build-fips-host-image, or pull
    the CI-built image from GHCR (klangk-host-fips). With
    KLANGKD_FIPS_MODE on, a containerized klangkd whose own OpenSSL is
    not FIPS-enforcing now refuses to boot instead of logging a warning
    (a control-host deployment still only warns). Docs:
    FIPS 140-3 Mode.

  • Login timing equalization (#2618). Login and resend-verification
    now burn one full password verify even when the account is unknown or
    OIDC-only, so response timing no longer reveals whether an account
    exists.

  • OIDC state cookie hardening (#2573). The OIDC callback no longer
    trusts the redirect_uri stored in the unsigned state cookie when
    exchanging the authorization code with the IdP; it now re-derives the
    value from hosting configuration the same way the login endpoint does.
    The cookie now carries only state, verifier, and cli_redirect.
    Defense-in-depth — not exploitable against a conforming IdP.

  • KLANGKD_PASSWORD_HISTORY_COUNT (#2582). How many previous
    passwords to remember per user (default 0 = disabled, max 24). When
    set, password changes, resets, and admin password sets are rejected
    with 400 if the new password matches the current or any remembered
    one; the old hash is retired into history (stored hashed, pruned to
    the window) on every set. /api/v1/config advertises the count as
    password_history_count.

  • WebSocket error responses (#1718). Terminal- and shared-terminal
    failure frames sent over the workspace WebSocket no longer include raw
    exception text (which could leak backend paths, image names, or
    tmux/podman internals to the caller). Clients now receive a fixed
    message (e.g. Failed to create window); the full exception detail is
    logged server-side instead.

  • OIDC cli_redirect userinfo bypass (#2571). The localhost-only
    guard on the CLI login redirect used prefix matching, so a crafted
    cli_redirect like http://localhost:1@attacker.example/ passed the
    check while actually routing to the attacker's host — a victim
    completing a normal IdP login had their session token redirected to
    it. The target is now parsed and must be plain http to localhost
    or 127.0.0.1 with a port and no userinfo; anything else falls back to
    the web flow.

  • /files/content now requires files-download (#2713). The file
    viewer's text-reader endpoint
    (GET /api/v1/workspaces/{id}/files/content) is gated by the
    files-download permission like /files/download (#2705), closing
    the remaining scripted bulk-read avenue for members with files
    alone. Operators who relied on text viewing without download must
    also grant files-download (or withhold files entirely). The web
    file viewer degrades gracefully: listings and metadata stay visible
    and the content pane reports the denial. See
    ACLs.

Added

  • host and instance fields on JSON log records (#3330). Every
    JSON log line (console in KLANGKD_LOG_FORMAT=json and the
    KLANGKD_LOG_FILE sink) now carries host (the emitting machine's
    hostname) and instance (the per-data-dir klangk instance id from
    <data_dir>/instance-id — the id the audit trail's app lifecycle rows
    will key on (#3329)), so SIEM streams from several hosts or instances
    are distinguishable. The id is resolved once per configure, never per
    record; a SIGHUP reload keeps the process's live instance id, and a
    data_dir change applies after the restart the reload warning already
    asks for.

  • Clear-Site-Data: "storage" on logout (#3328). The
    POST /api/v1/auth/logout response now carries the header on every
    path (live token, anonymous token, OIDC logout redirect), so the
    browser wipes the origin's web storage — localStorage, IndexedDB,
    cache storage, and service worker registrations — when a session ends.
    Browsers apply the header only in secure contexts: an HTTPS listener
    (KLANGKD_TLS_HOSTNAME set) or a localhost bind. On a plain-HTTP
    listener bound to a remote host the browser ignores the header and no
    wipe happens.

  • App lifecycle audit events (#3329). The daemon's own start and
    stop now appear in the structured audit stream: one app.start row
    per boot (build version, pid, listener) and one app.stop row per
    shutdown (pid, uptime, exit reason — the graceful signal or the
    fail-secure forced exit), written before the teardown steps so the
    row lands while the database is still open. A SIGHUP or scheduled
    recycle that swaps settings in place writes its own app.reload row
    — the process keeps running, so there is no start/stop pair to
    bracket it. System rows (no actor, target is the app itself),
    covered by the opt-in KLANGKD_AUDIT_HMAC_KEY tagging and the
    audit-record forwarder, listed by GET /api/v1/events/audit next to
    the identity and privilege events.

  • Air-gapped deployment guide (#2660). New deployment chapter
    (docs/deployment/airgapped.md) covering offline image transport,
    DNS and LLM configuration for disconnected networks, workspace
    package-mirror workarounds, and a recommended-settings checklist.

  • GET /health degraded status (#3308). The resource watchdog's
    last-known state now surfaces on /health: any monitored filesystem
    or host metric at warn/critical, or new audit-write failures in the
    latest watchdog window, flips the status to degraded and adds a
    detail block (per-filesystem path/usage/state, the memory and CPU
    rows, the audit flag, the last poll time). The response stays HTTP
    200 and the healthy payload is unchanged, so liveness probes and the
    instance-id check keep working; recovery restores ok.

  • Disk watchdog monitors state_dir (#3310). The resource
    watchdog's disk-capacity check now measures the filesystem holding
    KLANGKD_STATE_DIR in addition to the data directory, so a
    split-mount deployment (state dir on the OS disk, data dir on its
    own volume) gets resource.disk.warn / resource.disk.critical
    / resource.disk.recovered for both filesystems. Same-mount
    deployments alert exactly as before (device dedup collapses the
    two paths into one).

  • Host memory and CPU-pressure alerting (#3309). The resource
    watchdog gained two surfaces beside disk capacity: memory
    utilization of the machine containers run on (MemAvailable-based on
    a Linux host, pressed by the cgroup limit when klangkd itself runs
    memory-capped; on macOS the podman machine VM's own meminfo, read
    via podman machine ssh — containers live in that VM) and CPU
    pressure (PSI some avg60; no PSI on the kernel/VM disables that
    check with one logged warning). Crossing the thresholds sends
    resource.memory.* / resource.cpu.* notifications with the same
    transition, hysteresis, and refresh semantics as the disk events.
    The watchdog's master switch and poll interval are
    KLANGKD_RESOURCE_WATCHDOG_ENABLED /
    KLANGKD_RESOURCE_WATCHDOG_POLL_INTERVAL (the loop now covers four
    surfaces), with per-surface KLANGKD_MEMORY_WATCHDOG_* and
    KLANGKD_CPU_WATCHDOG_* thresholds beside the disk ones. All
    reloadable on SIGHUP. See
    Notifications.

  • Falco exec audit guide (#2780). New deployment chapter
    (docs/deployment/falco.md) documenting the verified procedure for
    running Falco 0.44.1 as a privileged container that captures every
    execve/execveat host-wide — including commands typed in klangk
    workspace terminals — to a JSON file an unprivileged consumer reads via
    a read-only bind mount. Records the verified field semantics
    (proc.vpid matches tmux #{pane_pid}; container.id enriches to the
    host container in the nested shape) and the mandatory livelock
    watchdog (falco#3822).

  • Built-in audit-record forwarding (#3252). Opt-in native
    forwarding of the audit records themselves to a SIEM —
    KLANGKD_AUDIT_FORWARD_URL (JSON POST batches to an HTTPS
    endpoint, with an optional KLANGKD_AUDIT_FORWARD_HEADER auth
    header) and/or KLANGKD_AUDIT_FORWARD_SYSLOG (RFC 5424 over
    TCP/TLS). Every new row of the three audit tables ships in order,
    at-least-once, resuming from a persisted cursor after a restart; a
    down target queues rows in the tables and retries with its own
    backoff (a healthy second target keeps its cadence), surfacing on
    /audit under forwarding. With neither set, nothing
    is read or sent. Both are SIGHUP-reloadable and validated at
    startup. See
    Logging.

  • Audit event HTTP method and Referer (#3255). Every
    audit_events row minted from an HTTP request now records the
    request's method and Referer header alongside the existing client
    IP and user agent (SV-222447), and GET /api/v1/events/audit serves
    both fields. Rows written before this change read null for the new
    fields, as does the workstation-binding violation row, which
    records only the presenting workstation pair. Stored Referer values
    are truncated at 2048 characters. The HMAC tag column set is
    unchanged, so offsite verification recipes keep working (see
    Audit Record Integrity).

  • Disk-capacity and audit-degradation detection (#3206). The
    resource watchdog checks the data directory, podman container
    storage, and KLANGKD_DISK_WATCHDOG_PATHS every minute, sending
    resource.disk.warn (75% used, the STIG storage warning point) /
    resource.disk.critical (90%) / resource.disk.recovered
    notifications on state transitions — hysteresis bands below both
    thresholds, one alert per filesystem per 5 minutes, and a dispatch
    the throttle swallows retried while delivery stays possible. It
    also summarizes
    audit-write-failure growth as one audit.failure per table, and
    GET /audit reports the identity-audit counter as
    identity_write_failures. Thresholds are
    KLANGKD_DISK_WATCHDOG_WARN_PERCENT / _CRITICAL_PERCENT; the
    master switch and poll interval are
    KLANGKD_RESOURCE_WATCHDOG_* (#3309 extended the loop to four
    surfaces). All reloadable on SIGHUP.

  • Data-level file audit events (#3257). Workspace archive
    exports/imports, file downloads and text reads, uploads, renames,
    and deletes through the files API now leave audit rows in the
    audit_events stream: file.download, file.upload,
    file.write, and file.delete, each with actor, workspace, path,
    and byte size (ASD-STIG SV-222471/472; an export's size is a
    pre-flight estimate). File rows are bounded by their own row-cap
    bucket under KLANGKD_AUDIT_EVENTS_ROW_CAP, so file traffic cannot
    evict account/privilege history. Visible in the admin Events view
    with the file icon and path in the detail expansion. Terminal I/O
    and container-internal changes stay out of scope by design.

  • Time-correlated merged audit stream (#3251). A new
    GET /api/v1/events returns one time-ordered stream merged across
    all three audit tables (identity/privilege, container lifecycle,
    egress consent), each row naming its origin table in source and
    embedding the full origin row — the SV-222439 replay view ("what
    did this actor do, in order" across components). Filters: time
    window (since/until), actor (id or email), workspace (id or
    name), event name; paged like the sibling views and gated on the
    same manage-events permission. The admin Events tab's new All
    subtab renders it with a source badge per row. See the
    API reference.

  • Inactivity sweep disables are audited (#3251). The
    dormant-account sweep now writes one user.disable row per
    disabled account (via=inactivity in the detail) — the audit
    stream's first user.disable rows, since an admin's disable/enable
    toggle records inside its user.update row. The sweep's
    auto-disables surface in the audit and merged events views.

  • Admin notifications (#3250). SA/ISSO notification of
    security-relevant events: account lifecycle (create, register,
    update, delete, unlock, disable, enable — including the inactivity
    sweep's auto-disables and SSO JIT-provisioned accounts), credential
    and group-membership changes, failed audit-trail writes, and
    memory-capacity start refusals. Delivered as email via
    KLANGKD_ADMIN_NOTIFICATION_EMAILS (the SMTP/sendmail transport the
    auth emails use) and/or one JSON POST per event to
    KLANGKD_ADMIN_NOTIFICATION_WEBHOOK_URL; with neither set,
    notifications are off. KLANGKD_ADMIN_NOTIFY_EVENTS narrows the
    event allowlist (default: every supported event). See the
    admin management docs.

  • KLANGKD_TRUSTED_CA_DIR (#3198). Operator-managed approved CA
    baseline for runtime SSL trust: when set, only CAs whose SHA-256
    fingerprint appears among that directory's .pem/.crt certs are
    trusted, via a staged copy (<state_dir>/ssl/approved) consumed by
    the backend bundle and workspace containers. Certs dropped into
    <KLANGKD_CUSTOMIZE_DIR>/certs are audited against the baseline;
    non-approved or unparseable ones are refused with a warning naming
    subject/issuer. A missing, unreadable, or cert-less baseline fails
    closed (no custom CAs trusted; previously-applied backend trust is
    revoked on reload). Unset (default) = no restriction. Reloadable on
    SIGHUP.

  • Self-contained frontend fallback fonts (#3228). The web UI
    bundles the Flutter engine's complete fallback-font set (the Noto
    script/emoji families, plus Roboto for the engine's boot-time default
    path, ~22 MB of woff2 parts) under
    assets/fallback-fonts/ and points the engine's
    fontFallbackBaseUrl at that same-origin mirror from
    web/flutter_bootstrap.js. Codepoints the bundled UI fonts lack
    (CJK terminal output, , emoji) now resolve from first-party
    bytes: no fonts.gstatic.com request is attempted, offline
    deployments render identically to online ones, and the e2e suite
    fails on any external-origin request. Refresh the set with
    python scripts/vendor_flutter_fallback_fonts.py after a Flutter
    toolchain bump.

  • Audit subtab in the admin Events tab (#3217). The Events tab
    now has a Containers / Audit segmented control; Audit lists the
    identity/privilege stream of #3205 (logins, account and group
    changes, ACL and workspace-role edits) newest-first with paging and
    event / actor / target filters. Rows expand to the read-only detail
    blob plus source IP and user agent. Same manage-events grant as
    the container history — no new permission wiring.

  • Structured audit stream for identity and privilege actions
    (#3205).
    A new audit_events table records account
    create/update/delete (admin and self-service), group and ACL
    changes, workspace role assignments and ownership transfers,
    login/logout/failed-login (with the issuing path in the detail), and
    session revocation — each row carrying actor, target, and the
    request's source IP and user agent. Covered by the opt-in
    KLANGKD_AUDIT_HMAC_KEY tagging; writes are best-effort and never
    fail the action they annotate. Queryable by manage-events holders
    via GET /api/v1/events/audit (paged, filterable by event, actor,
    target). Bounded by KLANGKD_AUDIT_EVENTS_RETENTION_DAYS (default
    365) and KLANGKD_AUDIT_EVENTS_ROW_CAP (default 100000,
    applied per class so a flood of unauthenticated failed-login rows
    can evict only other failed-login rows), swept
    hourly; both reloadable on SIGHUP. See
    Audit Record Integrity.

  • KLANGKD_TLS_HOSTNAME / KLANGKD_TLS_ISSUER /
    KLANGKD_ACME_EMAIL (#3192).
    Setting KLANGKD_TLS_HOSTNAME serves
    the browser listener over HTTPS with an automatically renewed
    certificate; unset — the default — keeps today's plain HTTP exactly,
    so outer-proxy deployments are unaffected. Certificates persist under
    <state_dir>/caddy-storage. With the default acme issuer the
    certificate comes from Let's Encrypt/ZeroSSL (public FQDN, reachable
    ports 80/443, KLANGKD_ACME_EMAIL for expiry notices);
    tls-issuer: internal self-generates from the proxy's internal CA
    instead — the TLS hop behind an outer proxy. Reloadable on SIGHUP;
    see the HTTPS Hosting deployment chapter.

  • KLANGKD_SESSION_IDLE_TIMEOUT_MINUTES /
    KLANGKD_PRIVILEGED_SESSION_IDLE_TIMEOUT_MINUTES (#3151).
    Idle
    session timeout: after this many minutes without an authenticated
    HTTP request or WebSocket frame, a session's token refresh is
    refused (and the token blocklisted) and its quiet WebSocket is
    closed (4001 → client logout). Admins-group members get the shorter
    privileged window — the lesser of the general setting and
    KLANGKD_PRIVILEGED_SESSION_IDLE_TIMEOUT_MINUTES (default 10;
    0 turns the split off). Armed tokens are capped at the window.
    0 on the general setting (the default) restores age-only expiry
    exactly. Both reloadable on SIGHUP.

  • KLANGKD_LOG_FORMAT / KLANGKD_LOG_FILE (#3156). New settings for
    SIEM/central-log forwarding. KLANGKD_LOG_FORMAT
    (text, the default, or json) switches the console log stream to one
    JSON object per line (timestamp, level, logger, message, exc_info
    when present); KLANGKD_LOG_FILE additionally writes that JSON stream to a
    file while the console keeps its own format — stdout can stay readable text
    while the file feeds rsyslog imfile/fluent-bit. uvicorn's startup/error/
    access records now flow through the same handler (previously they rode
    uvicorn's own text handlers), so the whole stream shares the format. Both
    are reloadable on SIGHUP; malformed values abort startup. The file sink
    follows external rotation (logrotate rename) and suspends itself with a
    warning instead of crashing if its path breaks at runtime. New rotation
    settings let the app own rotation instead: KLANGKD_LOG_FILE_MAX_BYTES
    (size trigger, 0 = off) and KLANGKD_LOG_FILE_ROTATE
    (hourly/daily/weekly/monthly, UTC boundaries) rotate the sink to
    numeric-suffix backups, retention via KLANGKD_LOG_FILE_BACKUP_COUNT
    (default 3). uvicorn access
    logs are now visible at the default INFO level (previously suppressed at
    WARNING), and the Logfire SDK's project-URL print is suppressed so it
    cannot inject a non-JSON line into the stream. See
    Environment Variables.

  • KLANGKD_PASSWORD_MIN_CHANGED (#3173). Minimum number of
    characters (edit distance) a self-service password change must alter
    from the current password. Enforced on POST /auth/change-password;
    forgot-password resets and admin-set passwords are exempt (no old
    plaintext is presented).
    0 (the default) disables the gate. Reloadable on SIGHUP; advertised
    via /api/v1/config so the web and CLI change-password forms
    pre-check inline.

  • KLANGKD_API_RATE_LIMIT (#3157). Per-client-IP API rate
    limiting, enforced in the backend: at most N /api/* requests per
    60s window per client IP, answered with 429 + Retry-After.
    Default 0 = off (a throttle on real traffic ships opt-in; 300 is
    the documented example budget). The first denial per client per
    window is logged at WARN. The client IP resolves through
    KLANGKD_TRUSTED_PROXY_CIDRS, so it is correct bare, behind the
    managed Caddy, or behind an outer trusted proxy. Static assets,
    /ws upgrades, /hosted/*, and /health never count against the
    budget. Reloadable on SIGHUP.

  • KLANGKD_PASSWORD_MIN_AGE_HOURS / KLANGKD_PASSWORD_MAX_AGE_DAYS (#3177).
    Password age policy: how many hours a
    password must be kept before a self-service change or forgot-password
    reset (admin resets bypass), and how many days before it expires. An
    expired password is refused at login, token refresh, and — like a
    disabled account — on the next authenticated request and WebSocket
    connect, with a machine-readable 403; the CLI and TUI login flows
    then prompt for a new password (POST /auth/change-expired-password,
    which auto-logins and is refused when password login is disabled).
    Local password accounts only — OIDC logins are unaffected. Both default
    to 0 (disabled); the recommended hardening values are 24 hours /
    60 days. Reloadable
    on SIGHUP; passwords set before the upgrade age from account creation.

  • must_change_password (#3172). Admin-chosen passwords now force a
    change at next login: creating a user
    with a password or resetting one from the admin console sets a
    must_change_password flag; the flagged session may do nothing except
    change its password (API requests 403 Password change required, new
    WS connections close with 4004 — including the consent-decider
    socket). Login and token-refresh responses carry the flag so clients
    drive a forced-change flow; both the web UI and the CLI TUI prompt
    until the password is changed. Admins can set/clear the flag from the
    user editor (local-password accounts only — flagging an OIDC account
    is refused, since it could never be cleared).

  • KLANGKD_AUDIT_FAIL_CLOSED (#3154). Opt-in fail-closed
    container auditing (security finding): the interactive API
    container transitions — POST start/stop/restart and delete's stop —
    write their container_events audit row before acting and refuse
    the request with a 503 when it cannot be written; create's eager
    start is skipped instead (the workspace row is already committed, so
    it is left created-not-started — the same shape as a drain/capacity
    refusal). WS-connect starts and autonomous transitions (idle
    timeout, eviction, logout, drain, shutdown sweep, crash teardown,
    boot reaps, crash-monitor restarts) are never gated. Default off
    keeps the best-effort behavior. A new /audit endpoint (public,
    like /health) reports write_failures (an in-memory counter
    bumped on every audit-write failure, best-effort paths included;
    zeroed on restart) and fail_closed so assessors can verify the
    mode. Reloadable on SIGHUP.

  • All five container images now publish on a release tag (#3140).
    Pushing vX.Y.Z publishes klangk-host, klangk-host-fips,
    klangk-workspace, klangk-workspace-fips, and the newly pullable
    klangk-network-sidecar to GHCR under that tag, built from the tagged
    commit. Versioned tags only: the floating :latest stays owned by the
    continuous builds. See
    Building Images.

  • /health now reports the instance id (#3057). The health endpoint
    returns {"status": "ok", "instance": "<id>"} so a caller can confirm
    it reached the intended klangkd (the id is the same one in
    <data_dir>/instance-id and the podman klangk.instance labels). The
    E2E harnesses use it to detect fixture-server port collisions.

  • Backup and restore docs (#2999). New "Backup and Restore" reference
    chapter covering the full-site backup set (data dir, labeled podman
    volumes with their labels, env + config file + file: secrets,
    customization tree, host bind-mount sources, locally-built images)
    and backup/restore procedures for both the packaged and Docker
    deployments, including the instance-id and volume-label constraints
    that make a restore succeed.

  • is_admin flag on /my-permissions (#2995). The response now
    carries an explicit instance-admin flag derived from admins-group
    membership (the CLI's status and the web app's admin gating read
    it). The /admin ACL tree is retired with it: no rows are seeded,
    /admin no longer appears in the permission map or the ACL browser,
    and migration m0027 deletes any stored /admin rows (they answered
    no check). Because the group's name is now load-bearing, the
    admins group can no longer be renamed or deleted (HTTP 400), and
    the name cannot be claimed by another group. See
    ACL reference.

  • KLANGKD_VOLUME_QUOTA_PER_USER (#2972). Per-user cap on
    instance-managed named volumes, enforced at both creation paths:
    the volumes API (a create past the cap returns 429 naming the
    setting) and the workspace-start auto-create of mounted named
    volumes (a clear start error). A per-user lock makes the cap exact
    under concurrent creates. 0 (the default) = unlimited — the
    create path is unchanged when no quota is set. Reloadable on
    SIGHUP.

  • workspace filter on GET /api/v1/events (#3006). The Admin →
    Events filter now accepts a workspace name as well as a workspace id:
    the new workspace query param matches an exact id or a workspace-name
    substring. The legacy workspace_id param keeps its exact-id behavior.

  • Live permission lists in the Sharing tab (#2986). Each role
    bucket (owners, collaborators, coders, spectators) now lists the
    permissions its group actually holds on the workspace, read live
    from the ACL — post-seed edits made in the Advanced ACL editor are
    reflected on reload. A * grant shows as "All permissions". The
    buckets span about three quarters of the screen width.

  • join-workspace permission (#2975). The workspace_connect
    gate — opening a workspace at all — now checks join-workspace
    instead of terminal. terminal keeps its name and becomes the
    Terminal-tab visibility signal: a member without it gets no Terminal
    tab, so a custom ACL can grant, e.g., files-only access
    (join-workspace + files-view). Migration m0024 copies every
    stored terminal ACE — Allow and Deny, on any resource the ACL
    ancestor walk consults — to a join-workspace sibling inserted
    directly after it, so first-match answers (including Deny-based
    exclusions and collection-level grants) survive the swap unchanged;
    nothing is renamed, and fresh seeds and both share flows (member,
    group) grant join-workspace alongside terminal.

  • README release badge (#2981). The README now carries a release
    badge showing the latest v* tag, driven by GitHub Releases —
    no manual updates needed.

  • Versioned documentation (#687). Each release tag now deploys its
    docs as a versioned subdirectory of the gh-pages branch (managed by
    mike, with the zensical version selector), instead of overwriting the
    whole site. The docs root redirects to the latest version. One-time
    operator action: after the first tagged deploy creates the branch,
    switch the GitHub Pages source to "Deploy from a branch: gh-pages".

  • Native YAML integers for port, egress_port,
    bridge_timeout_seconds, and idle_timeout_seconds (#2967).
    A
    bare YAML integer (port: 8997) now parses the same as the quoted
    string form — previously it was rejected at construction unless
    quoted. The deprecated proxy_port alias accepts a bare integer
    too. Env vars, quoted strings, and file:/cmd: indirection are
    unaffected.

  • KLANGKWS_FEATURE_OAUTH_PROVIDERS (#432). JSON list of OAuth
    device-flow providers (host, client_id, device_code_url,
    token_url, optional scope/username) that extends the git-credential
    device flow beyond GitHub to any RFC 8628 provider — self-hosted GitLab
    (17.1+, device flow enabled on the app) and other compliant hosts. A
    matching entry wins over the client-ID shorthands; a new
    KLANGKWS_FEATURE_GITLAB_OAUTH_CLIENT_ID shorthand covers gitlab.com
    the way the existing GitHub one does. The browser dialog now names the
    provider host ("Sign in to gitlab.com"), only https verification pages
    are auto-opened, and malformed provider responses fall back to the PAT
    dialog instead of hanging it. See
    GitHub Authentication.

  • Granular /admin tab permissions (#2940). The admin endpoints
    split off the monolithic admin gate onto one permission per tab:
    manage-users (Users), manage-invitations (Invitations),
    manage-groups (Groups), manage-server-schedule (Server), and
    manage-events (Events; renamed from container-events). Admins are
    unaffected — the seeded /admin * wildcard covers every name — and
    a whole tab can now be delegated to a non-admin via an Allow ACE on
    its sub-resource. manage-acls (Access Control browser) is
    root-equivalent: it can rewrite ACLs on any resource including
    /admin and /, so it is granted only to administrators. See
    ACLs.

  • Container events history API + admin Events tab (#2923). New
    GET /api/v1/events endpoint pages through the
    container_events audit table (#2915) newest-first, with an optional
    workspace_id filter and a total count, and the admin section gains
    an Events tab rendering it (when, workspace, event, actor, cause,
    container, network namespace). Both are gated on the dedicated
    manage-events permission over /admin/container-events (renamed
    from container-events in #2940 before any release): admins
    hold it via the /admin wildcard, and granting it to another
    principal on that resource delegates read-only audit access without
    full admin.

  • Container lifecycle audit trail (#2915). Every workspace
    container start/stop is now recorded in a new container_events
    table with the acting principal (user, agent, or system), the cause
    (api/create/ws_connect/auto_start/crash_restart | stop/restart/delete/
    crash_teardown/idle_timeout/eviction/logout/drain/shutdown), the
    podman container, and its role — workspace or network sidecar
    (sidecar create/teardown lands as system-caused sidecar_start/
    sidecar_stop rows; workspace rows carry the sidecar container id
    as network_namespace for egress-filtered workspaces). Labeled
    containers stopped by the shutdown/drain orphan sweeps, the boot
    reaps, and the sidecar dependent-container teardowns are attributed
    too (by their klangk.workspace label). Recording is best-effort
    and never fails the start/stop itself; rows accumulate under
    data_dir's SQLite DB (bounded by the #2924 prune knobs).

  • KLANGKD_CONTAINER_EVENTS_RETENTION_DAYS / KLANGKD_CONTAINER_EVENTS_ROW_CAP
    (#2924).
    Bound the container_events audit table (#2915): rows older
    than the retention window (default 90 days) are deleted, and when the
    table exceeds the deploy-wide row cap (default 10000) the oldest rows
    are trimmed keeping the newest. Swept once at startup, then hourly, by
    the consent sweeper's retention pass. Set either to 0 to disable that
    bound. Reloadable on SIGHUP (applies on the next sweep).

  • change-acls permission (#2764). Raw ACL editing is now gated
    on a dedicated resource-level permission instead of share:
    GET/PUT /api/v1/workspaces/{id}/acl (the Advanced ACL editor) and
    the role-group writes (POST/DELETE/PATCH /api/v1/workspaces/{id}/roles*, which can mint an owners- member)
    require change-acls; PUT /api/v1/acl/resource additionally
    requires it when the target is an individual workspace. The simple
    sharing surface (member and group shares with the fixed permission
    set) stays on share. Owners are covered by their * wildcard, and
    migration 0017 backfills change-acls onto existing effective
    share holders, so workspace-side behavior is unchanged for them.
    Integrators calling PUT /admin/acl/resource against individual
    workspaces must now hold change-acls there (grant it on the
    workspace, or on /workspaces / / for deploy-wide coverage). See
    ACL.

  • Native YAML booleans for string-typed boolean settings (#2796).
    allow_sudo, allow_autostart, disable_registration,
    disable_invites, disable_tmux, prevent_insecure_jwt_secret,
    allow_insecure_no_auth, reject_proxy_headers, and test_mode now
    accept a bare true/false in the YAML config file (previously
    rejected at boot unless quoted). Env vars and quoted strings behave
    exactly as before.

  • Admission control: KLANGKD_ADMISSION_MEMORY_ENABLED,
    KLANGKD_ADMISSION_MEMORY_MARGIN (#2525).
    Opt-in start-time
    host-capacity check: before a workspace container is created,
    klangkd compares available host memory (MemAvailable, plus the
    cgroup limit when klangkd itself is memory-capped; vm_stat on
    macOS, capped by the podman machine's configured memory — containers
    run in that VM, whose default 2048 MiB is far below the Mac's RAM)
    against the workspace's resolved memory limit plus a deploy-wide
    reserve (default 1g). A start that does not fit fails
    fast with a distinguishable 503 / WebSocket error ("host at capacity:
    1.2 GB available, workspace wants 4 GB") instead of deferring the
    failure to the kernel OOM killer. Default off (with the default 8g
    limit, small hosts would be refused every start); skipped when no
    memory limit is configured. Reloadable on SIGHUP.

  • KLANGKD_MAX_RUNNING_WORKSPACES_PER_USER (#2525). Deploy-wide
    cap on concurrently running workspaces per user, checked at start
    time (the k8s ResourceQuota analogue). A user at the cap gets a
    clear "stop a workspace first" 503 / WebSocket error. 0 (the
    default) = unlimited. Reloadable on SIGHUP.

  • KLANGKD_CLASSIFICATION_BANNER (#2768). Deploy-wide default
    classification marking (free text) for the always-visible marking banner
    (markings pinned at the top and the bottom of screens).
    Per-workspace override via the classification_banner field on
    POST/PUT /api/v1/workspaces, klangk create/edit --classification-banner, and the create/edit UIs; the
    workspace-created hook can set it like any other attribute. Markings are
    validated (one line, printable, ≤120 chars — control and invisible format
    characters rejected); a malformed KLANGKD_CLASSIFICATION_BANNER aborts
    startup / is denied on SIGHUP reload. The web workspace page renders the
    banner pinned at the very top and bottom of the screen (color-coded by
    marking, scaled to stay fully legible — never ellipsized), and the TUI
    workspace detail shows a matching status line; marking edits propagate
    live to the owner, editors, and shared members. With no marking
    configured (the default) no banner is rendered and no screen space is
    reserved. Downloaded/exported files are not marked — the screen banner is
    the scope.

  • KLANGKD_BROWSER_DELEGATE_ENABLED (#2710). Deploy-wide kill
    switch for the browser-delegate bridge (the workspace-token-gated
    /api/v1/browser-delegate{,/stream} endpoints that let a container
    drive the user's browser tab — a workspace-data read channel that
    bypasses file permissions). Defaults to true; set false to return
    403 from both endpoints, stop registering browser tabs for bridge
    routing, stop attaching a browser ID into new terminals (terminals
    already running keep a stale klangk-browser-id, but their bridge
    POSTs get the same 403), and advertise
    browser_delegate_enabled: false via /api/v1/config so the web UI
    stops answering bridge requests. Reloadable on SIGHUP. See
    Browser Bridge.

  • files-write permission (#2705). The mutating files endpoints —
    upload (POST …/files/upload), rename (POST …/files/rename), and
    delete (DELETE …/files) — now require the new files-write
    permission in addition to files. New shares (members, groups,
    coder/collaborator roles) grant it; migration m0012 grants it
    alongside every Allow files-download grant, so existing deployments
    keep current behavior. Without the permission the file viewer hides
    every mutating affordance (drag-and-drop, upload hints, Rename/Delete
    in the context menu) and editor renderers are read-only.

  • files-download permission (#2705). The workspace file-download
    endpoint (GET /api/v1/workspaces/{id}/files/download) now requires
    the new files-download permission in addition to files, so
    download can be withheld from members who can otherwise browse/read
    files in the viewer. New shares (members, groups, coder/collaborator
    roles) grant both permissions; a schema migration mirrors existing
    files grants so current behavior is unchanged. Without the
    permission the file viewer hides its download affordances and binary
    renderers (image, PDF, video, spreadsheet) cannot fetch bytes — and
    since #2713 the text reader (/files/content) requires the
    permission too. The CLI/TUI expose no file-download affordances.

  • KLANGKD_WORKSPACE_CREATED_HOOK (#2762). New customize-dir hook:
    a deployment-local Python file (point the env var at it, like
    KLANGKD_OIDC_LOGIN_HOOK) whose on_workspace_created(workspace,
    actor) runs after every workspace creation — create, import, and
    duplicate — and may mutate workspace attributes (validated, persisted)
    and rewrite the workspace ACL. Hook failures are logged and never
    fail the create; reloaded on SIGHUP. See
    Customizing a Deployment
    for the API; a commented example ships in
    customize/custom/hooks/workspace_created.py.

  • De-noised group lists in the UI (#2752). The admin Groups tab
    defaults to source=manual, with a "Workspace role groups" filter
    chip to include the seeded per-workspace groups. The ACL editor's
    add-entry picker offers manual groups plus the groups already
    referenced by the resource's ACEs — other workspaces' role groups are
    omitted — and the entries table is unchanged. Group (and picker user)
    fetches now walk every page of the paged envelope instead of silently
    truncating at 200 rows, and the picker dropdowns ellipsize long names
    (e.g. UUID-suffixed role groups) instead of overflowing.

  • Group source marker and filtering (#2750). Groups now carry a
    source column: manual for human-managed groups,
    workspace-role for the four role groups seeded per workspace.
    GET /api/v1/groups and GET /api/v1/admin/groups accept a source
    query filter and include it in each row, so pickers can hide the
    machine-generated role-group names. Existing rows are backfilled by a
    schema migration. Role groups are now also rejected as share/ACL
    targets outside their own workspace (HTTP 400), and their names cannot
    be changed (HTTP 400) — the name is the teardown/scope-guard key.

  • Per-workspace sudo lock-down (#2017). allow_sudo in the workspace
    settings bag (set with klangk create/klangk edit
    --no-sudo/--sudo, the web and TUI Allow sudo toggles, or
    PATCH /workspaces/{id}/settings) locks a single workspace out of
    passwordless sudo even on a deploy with KLANGKD_ALLOW_SUDO on.
    KLANGKD_ALLOW_SUDO stays the ceiling: a workspace can never grant
    itself sudo on a deploy that forbids it. The rule applies at the next
    container start; the toggle is only shown when the deploy allows sudo
    (sudo_available on /api/v1/images).

  • Workspace export/import preserves the home layout (#2722). workspace.json
    now carries per_handle_home, and import honors the archive's layout even
    when the server's KLANGKD_PER_HANDLE_HOME default differs. Archives
    exported before the feature import as per-handle homes.

  • Per-handle home is choosable on every create and edit surface (#2721).
    The web create dialog and Settings tab, the TUI create/edit screens, and
    klangk create/klangk edit (--per-handle-home/--shared-home) all
    send the workspace's home layout. Create forms pre-reflect the server
    default (KLANGKD_PER_HANDLE_HOME, surfaced as default_per_handle_home
    on /config); a flip on an existing workspace applies from the next
    connect/start. See Workspaces.

  • Service session HOME is always the shared home (#2717). The
    service tmux session now runs with HOME=/home/klangk pinned as a
    constant under both home layouts, and /home/klangk is created and
    populated from the image skeleton before the session's first login
    shell — including on the server-boot auto-start path, where no user
    ever connects first. This gives the service environment parity with
    member setup: exports written to /home/klangk/.profile reach the
    service (with the shared-mutable-state consequence that typed
    commands land in the shared .bash_history). The agent-private home
    provisioning is gone; KLANGKWS_AGENT_HOME remains baked as the
    constant /home/klangk, so sandbox setup.sh scripts using
    export HOME="${KLANGKWS_AGENT_HOME}" keep working (a no-op on
    shared-home workspaces).

  • per_handle_home now selects the home layout at runtime (#2720).
    A workspace created with per_handle_home=false (see
    KLANGKD_PER_HANDLE_HOME, #2719) now actually serves the shared
    layout: every connection — and exec sessions, the health-check probe,
    and the service tmux session — uses the single shared /home/klangk
    (the container user's own home), with no /home/{handle}
    .users/{user_id} symlinks and no per-user skeleton population.
    Changing your handle no longer re-links a home on this layout. The
    default (true, per-handle homes) is unchanged.

  • KLANGKD_PER_HANDLE_HOME (#2719). Deploy-wide default for the home
    layout of new workspaces: true (default) = per-handle homes, the
    current behavior; false = a shared klangk home. Overridable per
    workspace via the new per_handle_home field on POST /workspaces
    (and editable later with PUT /workspaces/{id} — a flip applies from
    the next connect/start); exposed in GET /workspaces payloads;
    duplicates copy it, imports follow the deploy default.
    Reloadable on SIGHUP. See
    Environment variables.

  • Admin page → Server tab (#2684). Admins can now schedule a server
    stop or recycle from the Admin page: pick an action and either an
    absolute time (date/time pickers) or a delay (2h, 90m, 45s, or a
    bare number of minutes). Pending schedules list soonest-first with the
    same live countdown clients see, and each can be cancelled with a
    confirm step. The list follows the live server_schedule snapshot, so
    changes made by other admins appear immediately. The API remains
    available for scripting; see
    Server Scheduling.

  • TUI status bar on every screen (#2689). The server / user / last login status line — including live segments such as the
    scheduled stop/recycle countdown, host notices, and reachability
    flags — now renders on every TUI screen (workspace detail, create/edit
    forms, server switch, login), not only the workspaces list. The line
    stays current while you work inside a workspace screen and no longer
    disappears when navigating.

  • terminal-open-cmd / KLANGKC_TERMINAL_OPEN_CMD (#2685). New CLI
    setting (klangk.yaml or envvar) that names the command used to open a
    new terminal window, e.g. konsole -e. When set, selecting a
    terminal in the TUI spawns klangk shell in a new terminal window
    instead of suspending the TUI and taking over the current terminal;
    the TUI stays running, and the window closes on its own when the shell
    disconnects (a holding flag like --hold keeps it open if wanted). If
    the command can't be launched, the TUI shows an error and falls back
    to the previous inline behavior. See
    CLI reference.

  • Clearer shell exit (#2685). klangk shell now says how to exit
    ("Exit this shell: press Enter, then ~.") and prints
    Disconnected from <workspace>. after a clean disconnect, so tmux's
    [exited] line reads as a normal exit instead of a crash. The
    consent-popup wrapper's cleanup no longer sprays
    no server running on …sock into the terminal after the shell ends.

  • Scheduled server stop/recycle (#2661). Admins can schedule a
    server stop or recycle at an absolute time or after a delay
    (POST /api/v1/server/schedule with
    {action: "stop" | "recycle", at | in_seconds}; list/cancel via
    GET/DELETE on the same resource). Schedules persist in the DB
    across klangkd restarts and fire without anyone connected. A
    stop runs the graceful TERM/INT path and the process exits
    (code 0) — the service manager owns what happens next; a recycle
    runs the SIGHUP graceful runtime recycle in-process (listener and DB stay
    up) and never exits. In both, workspaces are drained gracefully and
    every connected client sees a live-countdown notification: a banner
    in the Flutter UI (Server stops at 23:00 (in 1h 12m — workspaces stop)) and a server: stop at 23:00 (in 1h 12m) status line in the
    TUI. See Server Scheduling.

  • EX_CONFIG exit status 78 for deterministic config errors (#2666).
    When klangkd refuses to boot over bad configuration — e.g. a
    KLANGKD_DEFAULT_PASSWORD that violates the password policy, password
    mode without a staged password, auth_modes: none on a non-loopback
    bind, or a containerized FIPS backend with non-FIPS OpenSSL — it now
    exits with status 78 instead of uvicorn's generic startup-failure
    status, so a first-boot misconfiguration no longer presents as an
    endless restart loop. Supervisors can stop retrying it (systemd:
    RestartPreventExitStatus=78). See
    Process signals for the exit-status table.

  • Graceful stop on SIGTERM/SIGINT (#2527). TERM/INT shutdown now
    broadcasts a host_shutdown WebSocket event (so clients render

  • Graceful stop on SIGTERM/SIGINT (#2527, #2664). TERM/INT shutdown
    now broadcasts a host_shutdown WebSocket event (so clients render
    "server went away" instead of reconnect-looping), refuses new
    workspace starts, waits up to KLANGKD_QUIESCE_TIMEOUT seconds
    (default 15) for in-flight HTTP requests to finish, and drains every
    running workspace through the same graceful path as SIGHUP (terminal
    stop frames + container_stopped with reason host shutdown)
    before uvicorn's exit sequence runs. A drain failure is logged and
    never blocks the exit; a SIGHUP arriving during shutdown is ignored.
    Clients surface host_shutdown / server_recycle / host_started as
    transient, non-blocking notices (web UI snackbar, TUI status line +
    toast) — auto-reconnect is never visually impeded. Docs:
    Signals.

  • Graceful SIGHUP restart + KLANGKD_QUIESCE_TIMEOUT (#2527,
    #2664).
    SIGHUP is now a full graceful restart: new workspace
    starts are refused, in-flight HTTP requests get
    KLANGKD_QUIESCE_TIMEOUT seconds (default 15) to finish, running
    workspaces are stopped gracefully (concurrently per workspace, each
    with a 5s podman stop grace); the reloaded config is applied, and
    the runtime recycles (drained workspaces are not restarted — only
    auto_start ones return).
    Clients get server_recycle events with a phase field and a final
    host_started broadcast; each phase is logged. Starts stay refused
    until the post-restart container reaps finish, and a failed restart
    logs, attempts a startup recovery, and exits (code 1) if recovery
    fails — the node never lingers half-restarted. Invalid config still
    denies the restart with nothing touched. Docs:
    Signals.

  • Decommissioning guide (#2593). New deployment chapter
    documenting the decommissioning notification chain (users, admins, integrators,
    infrastructure owners) and the shutdown sequence: workspace export, graceful
    stop, data disposal, and secret revocation.

  • KLANGKD_FIPS_MODE (#2570, #2591). Opt-in FIPS enforcement:
    every workspace container must prove an actively-enforcing OpenSSL
    FIPS provider when klangkd starts or adopts it (distro-agnostic
    probes — provider-aware digest rejection, or an SHA-2-only
    fips=yes approved set via the openssl CLI); a container that
    cannot prove it is removed and its start refused. The klangkd
    process's own OpenSSL is probed once at startup and logged for
    audit. A new klangk:build-fips-image devenv task builds the FIPS
    workspace image variant. See
    FIPS 140-3 Mode.

  • Host memory-pressure eviction (#2526). When memory availability
    stays below KLANGKD_MEMORY_EVICTION_THRESHOLD_PERCENT (default 10%)
    for KLANGKD_MEMORY_EVICTION_SUSTAIN_POLLS polls (default 3 × 10s),
    klangkd gracefully stops the least-recently-active workspace with no
    connected clients — one per poll — until availability recovers to
    KLANGKD_MEMORY_EVICTION_RECOVERY_PERCENT (default 15%, hysteresis);
    the stop uses the idle-stop path (state preserved, next connect
    restarts) and emits a workspace_evicted WS event. Availability is
    measured platform-aware: /proc/meminfo on Linux (plus the cgroup
    limit inside memory-limited containers, e.g. Docker -m), and
    vm_stat/sysctl on macOS. Workspaces with live clients and
    workspaces pinned never-stop (idle_timeout 0, e.g. auto-started
    boot services) are never chosen while an idle one exists; on by
    default — disable with KLANGKD_MEMORY_EVICTION_ENABLED=false. All
    settings reload on SIGHUP.

  • Crash recovery for workspace containers (#2524). Unexpectedly-dead
    workspace containers (OOM kill, non-zero exit, external removal) are now
    detected by a liveness sweep, and the death events carry the classified
    cause — an OOM kill names the workspace's effective memory limit (e.g.
    "OOM-killed at 8g memory limit") instead of surfacing as a generic
    death. Set KLANGKD_CONTAINER_RESTART_ENABLED=true to also auto-restart
    such workspaces after an exponential backoff (default 5s → 10s → 20s,
    capped at 60s; KLANGKD_CONTAINER_RESTART_BACKOFF_SECONDS), with at
    most KLANGKD_CONTAINER_RESTART_MAX_RETRIES (default 5) attempts —
    exhaustion leaves a visible crash-loop state on
    GET /workspaces/<id>/status instead of an infinite restart loop.
    Expected stops (user stop, idle stop, delete, logout) never restart.
    Default off: recovery stays manual.

  • Resend-verification lockout (#2618). Failed password checks on
    POST /auth/resend-verification now count toward the login lockout
    (KLANGKD_LOGIN_LOCKOUT_*), keyed like login on the account's email.
    A locked-out account gets 429 there too; a correct check on an
    unverified account clears the counter, matching login semantics.
    The 60s per-email resend cooldown is unchanged.

  • KLANGKD_INACTIVITY_DISABLE_DAYS (#2588). Accounts whose newest
    activity signal — last authenticated API access (tracked per user,
    migration 0005), last login, or creation — is older than the window
    (default 35 days; 0 disables) are disabled by an hourly sweep.
    Login, token refresh, and authenticated requests then fail with
    403 Account disabled and live WebSocket connections are closed
    (4001 → client logout) until an admin re-enables the account via
    PATCH /api/v1/users/{id}. Admin-group members and the system
    agent are exempt; the setting is reloadable on SIGHUP. See
    Authentication.

  • Last successful login time (#2583). Every login (password,
    SSO, no-auth, and the auto-login after register/verify/reset/invite
    acceptance) now stamps a last_login_at timestamp on the user.
    GET /auth/me reports it, the TUI main-screen status bar shows it,
    and klangk account show prints it — so users can spot unexpected
    access to their account. Applied as schema migration 0002.

  • Concurrent-logon audit records (#2586). Each session now records
    the workstation it was established from (effective client IP +
    user agent; applied as schema migration 0004). When a login is
    concurrent with an active session from a different workstation,
    klangkd writes an audit record to the server log — the signal to
    review for shared or stolen credentials. The new
    GET /api/v1/users/{id}/sessions endpoint lists a user's
    active sessions with their workstations. See
    Authentication.

  • KLANGKD_MAX_SESSIONS_PER_USER (#2585). New setting that caps
    how many concurrent login sessions a user may have (default 0 = no
    limit). When a new login pushes a user past the cap, the oldest session
    is revoked via the token blocklist (its next HTTP request gets 401;
    its next WebSocket connect is rejected with 4001). Token refresh
    keeps the same slot, and expired sessions never count. Reloadable on
    SIGHUP. See Authentication.

  • KLANGKD_PASSWORD_REQUIRE_{UPPER,LOWER,DIGIT,SPECIAL} (#2581).
    Character-class complexity requirements for passwords: each setting is
    the number of characters of that class a password must contain
    (e.g. 2 = at least two uppercase letters), 0 (the default) = no
    requirement. Enforced on registration, password change/reset, invite
    acceptance, and admin set-password; advertised to clients via
    password_requirements in /api/v1/config for inline validation in
    the web UI and CLI.

  • FIPS 140-3 workspace image (#2570, #2577). New
    src/containers/workspace/Dockerfile.fips variant builds on the
    workspace image with the CMVP-validated OpenSSL 3.1.2 FIPS provider
    (certificate #4985): system OpenSSL, python, and Node.js (including
    the pi coding agent) route through the validated module, non-approved
    algorithms fail closed, and the build verifies activation
    automatically. Docs: FIPS 140-3 Mode.

  • KLANGKD_EGRESS_CONSENT_RETENTION_DAYS / KLANGKD_EGRESS_CONSENT_ROW_CAP
    (#2303).
    The egress_consent table is now bounded on long-lived deploys:
    a retention window (default 30 days; 0 disables) deletes terminal rows
    older than it, and a per-workspace row cap (default 2000; 0 disables)
    trims the oldest rows when a workspace floods decided requests past the
    cap. Verdicts still in effect (forever, tilrestart, or a timed window
    not yet elapsed) are enforcement state and are never pruned; they leave
    via workspace deletion or the tilrestart reap as before. Swept at
    startup and hourly (wall-clock deadline — event traffic never postpones
    it) by the consent monitor; both settings are reloadable on SIGHUP.

  • KLANGKNETWORK_EGRESS_ACTIVITY_GATE forwarding (#2514). The
    sidecar's idle-activity report interval is now honored when set in
    klangkd's environment (forwarded to the sidecar like
    KLANGKNETWORK_EGRESS_MIN_TTL). Operators can lower the default 60s
    gate on deploys with short idle timeouts so egress-only workspaces'
    keep-alive bumps stay prompt relative to the check interval.

  • Static reject list on the Net Rules tab (#2503). The tab now shows
    the workspace's rejected_domains (names the network sidecar blocks
    unconditionally, e.g. from a deny forever verdict) in a read-only
    section below the static allow-list. Editing stays in the workspace
    settings panel.

  • Pause egress filtering from the web UI (#2494). The workspace page's
    Net Rules tab now has a pause control (Unpause / Pause 15m / 1h / 1d)
    that silences consent prompts workspace-wide for a window, matching the
    consent-decide TUI control (#2332). Requires the same
    share-terminals permission as the TUI; the server nacks otherwise.

  • Egress request-flow diagram (#2376). New "Anatomy of an egressing
    request" page under Architecture: a Mermaid flowchart of a single
    egressing request — DNS gate → NFQUEUE SYN gate → consent loop →
    verdict — plus the host-matching grammar, the allow-vs-deny asymmetry,
    and persistence boundaries. Linked from
    Egress Filtering, whose spec
    grammar bullets were also corrected to the nginx-style scopes
    (bare host = apex only, .host = apex + subdomains) of #2377.

  • Interactive egress consent documented (#2247). The
    egress filtering page
    now covers the interactive consent mode end-to-end: egress modes and
    defaults, deciders and held connections, decision durations, pause and
    revoke, the audit trail, operator settings, and the security model.

  • klangk shell consent-decider popup (#2383). Shelling into an
    interactive-egress workspace now wraps the shell in a local tmux that
    floats the egress-consent decider over it as a tmux display-popup, so
    held egress requests can be acted on without leaving the shell. The shell
    itself is unchanged (the normal container tmux, full window machinery
    incl. the status-bar +); the outer local tmux is nearly invisible
    (C-a prefix, no status bar, mouse passes through to the container tmux).
    C-a p reopens the popup, q hides it (decider stays registered), Q
    quits for real. Falls back to the plain shell when host tmux < 3.2, stdin
    is not a tty, or --no-consent-popup is passed.

  • klangkd doctor tmux version check (#2383). Doctor now reports the
    host tmux version and warns when it is below 3.2 — the minimum for the
    upcoming TUI consent-decider popup over the shell (tmux display-popup
    landed in 3.2). Below 3.2 the shell layer will fall back to a plain
    attach, so the check is a warning, not an error.

  • klangk consent-decide persistent popup role (#2383). The decider
    accepts internal --popup-socket / --popup-session options (set by the
    shell-layer wrapper) for its upcoming persistent role inside a hidden tmux
    session: in that role q hides the popup viewer (detaching it, leaving the
    decider registered) and Q confirms a real quit. Standalone q still
    quits immediately.

  • Egress traffic extends the workspace idle timeout (#2485). The
    network sidecar now samples real workspace egress — any TCP/UDP traffic,
    including long-lived connections and non-DNS UDP that the existing DNS/SYN
    hooks (#2479) miss — and resets the container's idle timer while bytes are
    flowing, so an egress-only workload is no longer reaped mid-transfer.
    Best-effort and scoped to exclude the sidecar's own control traffic. No new
    config: it reuses KLANGKNETWORK_EGRESS_ACTIVITY_GATE (default 60s) as the
    sample cadence.

  • KLANGKNETWORK_EGRESS_UPSTREAM — operator-pinnable sidecar DNS upstream (#2424).
    When set in klangkd's environment, the network sidecar's FQDN proxy forwards
    workspace DNS to this resolver verbatim instead of auto-detecting a host
    resolver. An operator may want every filtered workspace to use a specific
    resolver (e.g. a corporate DNS); the interactive-egress smoketest also uses
    it to point the sidecar at a controlled-DNS test fixture so chosen hostnames
    resolve to single stable test IPs. Absent, behavior is unchanged (auto-detect).
    Mirrors the existing KLANGKNETWORK_EGRESS_MIN_TTL / SWEEP_INTERVAL forwarding.

  • KLANGKD_CONTAINER_TMP_SIZE + settings.tmp_size (#2378). The
    per-workspace /tmp tmpfs size is now configurable (e.g. 2g, 512m);
    the deploy default stays 2g (the prior hardcoded value), so existing
    installs are unchanged. Set the env var empty to mount /tmp with no
    size= option (podman then sizes it at half of RAM). Exposed in the
    Flutter create/settings dialogs and the TUI create/edit form.

  • egress_mode: "allow" — default-permit egress (#2406). A third
    egress mode alongside static (default-deny) and interactive
    (consent-gated). An allow workspace permits every host except names in
    rejected_domains (NXDOMAIN'd at the sidecar DNS layer); off-list egress is
    recorded through the consent pipeline for observability and auto-allowed
    with no consent prompt, behaving as if an internal always-allow decider were
    registered. External consent deciders are refused (as with static). The
    network sidecar runs when one is configured (for logging + reject-list
    enforcement) but degrades to plain unrestricted egress when filtering isn't
    set up, so it never fail-closes. klangk sandbox now creates allow-mode
    workspaces instead of the prior static-no-list-unrestricted degenerate case.

  • Egress-mode picker in the TUI + Flutter create/edit dialogs (#2409). The
    workspace create and edit forms now expose an egress-mode selector
    (allow / static / interactive, default interactive), so the mode is
    settable from the clients rather than API-only; a change on a running
    workspace applies on the next start/restart (both clients prompt). The
    allow mode itself landed in #2406.

  • rejected_domains in the TUI + Flutter workspace dialogs (#2386). The
    static deny-list is now editable end to end, mirroring allowed_domains:
    the TUI create/edit forms (a second list editor in the Netfilter pane, with
    focus-aware Delete/'e' for either list), the Flutter create + settings
    dialogs, and the klangk create/edit --reject CLI flags. The shared
    validator rejects CIDR specs for rejected_domains up front (NXDOMAIN is
    name-level), matching the API. The list page also badges a workspace whose
    rejected_domains is set but netfilter is disabled.

  • rejected_domains workspace setting + sidecar enforcement (#2367).
    The deny counterpart to allowed_domains: a persisted, host-only list whose
    names the network sidecar NXDOMAINs unconditionally (no resolution, no SYN,
    no consent prompt), in both static and interactive egress modes, taking
    precedence over the allow-list and consent. The grammar mirrors
    allowed_domains (bare = exact apex, .host = apex + subdomains, *.host =
    subdomains only); CIDR specs are rejected at the API (NXDOMAIN is name-level).
    Configurable via the create/update/clone/import workspace API. In static
    mode a reject-only workspace (no allowed_domains) is deny-all
    (fail-closed); the reject list is a useful blocklist alongside an allow-list
    or in interactive mode. Static mode itself is being phased out in favor of
    interactive-everywhere. The
    TUI/Flutter dialogs are a follow-up (#2386); the forever deny verdict
    that mutates this list at runtime is #2369.

  • Consent rules-management tab in the Flutter workspace (#2387).
    Interactive-egress workspaces now show a Rules tab in the workspace
    IDE tab strip (alongside Files/Terminal/Settings), the Flutter counterpart
    of the TUI consent-decide rules screen. It lists the static allow-list,
    active consent allows (with expires in 5m / until restart / forever
    labels), and active denies (with remaining window), all live off the
    existing egress_rules stream. Each active verdict has a Revoke action
    (confirm → the row leaves once the server acks; a failed ack surfaces an
    error and leaves the rule enforced). It mirrors the TUI exactly; static
    allow-list entries are not revocable from this tab.

  • forever egress-consent deny persists across restarts (#2369). The deny
    counterpart of the forever-allow: a deny with duration=forever appends
    the host to the workspace's rejected_domains, which the sidecar re-reads on
    (re)start and NXDOMAINs unconditionally. The deciding connection still gets
    its immediate in-memory REJECT; the list mutation makes the deny durable.
    Unlike the allow side, a port-less deny (e.g. ICMP) is persisted as a bare
    host -- reject enforcement is name-level, so blocking the whole host is the
    safe unit of a deny. Best-effort (failures swallowed). Revoking must clear
    both the list entry and the audit row (#2370).

  • Pause egress-consent filtering (#2332). A workspace-level control in
    the consent-decide TUI (Pause: 15m | 1h | 1d | Cancel) silences ALL
    consent prompts for the workspace for the chosen window: a destination with
    no allow-list rule and no in-effect recorded verdict is auto-allowed (no
    hold) instead of prompting. The pause does not bypass policy --
    allowed_domains/rejected_domains rules and existing egress_consent
    verdicts (a recorded deny still blocks) keep applying. The window
    self-expires (the gate re-checks on every connection), the status line
    shows the remaining time, and a refreshed egress_rules frame carries the
    live paused window to every decider.

  • reject_list in the egress_rules frame (#2370, #2340). The read-only
    rules view (ConsentCoordinator.rules_frame) now surfaces the workspace's
    rejected_domains alongside the existing allow_list, so deciders see the
    static deny-list in the rule-management screen.

  • forever egress-consent allow persists across connections and restarts
    (#2368, #2372).
    An allow with duration=forever allow-lists the host for
    the rest of the session AND across container restarts: the sidecar treats the
    host as live allow-listed (so a later connection that resolves to a
    CDN-rotated IP passes without re-prompting, #2372), and klangkd appends the
    consented host:port to allowed_domains, which the sidecar re-reads on
    (re)start (#2368). The deciding connection still gets its in-memory ACCEPT
    immediately; the persisted entry is port-scoped (the port the decider was
    shown) and de-duplicated. The forever deny counterpart is a follow-up
    (#2369).

  • Static egress mode refuses consent deciders (#2394). A workspace-scoped
    consent decider connecting to a workspace with egress_mode = "static" is
    now refused at registration with a 4003 Forbidden close
    (workspace egress mode is static), so the static/interactive boundary is
    structural rather than only enforced at hold time. Deploy-wide deciders are
    unaffected. The coordinator's existing _is_interactive gate remains as
    defense-in-depth.

  • Revoke action in consent-decide (#2341). On the rules screen
    (r), focus an active consent allow/deny row and press x to revoke it:
    klangkd drops the sidecar rule and marks the verdict spent, so the row
    leaves the list immediately. A failed revoke (sidecar unreachable / no ack)
    flashes revoke failed — still in effect and leaves the row enforced — a
    still-active rule is never silently hidden. Static allow-list rows are not
    revocable from this screen (edit them in workspace settings).

  • Read-only rules screen in consent-decide (#2340). Press r in the
    consent-decide TUI to switch from the held-request queue to a second,
    read-only screen listing every egress decision currently in effect for the
    workspace: the static allow-list, active consent allows (with expiry such
    as expires in 5m / until restart / forever), active denies (with
    remaining deny window), and the pause window when filtering is paused
    (#2332; hidden until that lands). q/Esc returns to the queue. The
    WebSocket worker stays connected across the switch, so holds keep arriving
    and the list updates live from the egress_rules frame (#2338). Revoking a
    row is a separate follow-up (#2339/#2341).

  • Interactive egress-consent banner in the web UI (#2246). Workspaces
    in interactive egress mode now show a banner on the workspace page listing
    pending held egress requests (host:port, process, countdown) with per-row
    Allow/Deny verdict buttons, alongside the consent-decide
    TUI; verdicts go live over the /ws/consent-decider stream. Server error
    frames, verdict send failures, and verdicts attempted while disconnected
    surface as a transient flash so a rejected/lost verdict is never silent.

  • Per-request duration for egress-consent verdicts (#2328). A verdict
    now carries a duration (once | 5m | 15m | 1h | 1d | 1w |
    restart | forever, default restart). The consent-decide TUI shows a
    per-row duration selector (click to choose; selecting does NOT submit -- only
    Allow/Deny submit with the chosen duration). The sidecar honors it: an allow
    learns the IP for that long (once = this connection only, no learn); a deny
    REJECTs (tcp-reset) for that long. restart = the workspace container's
    lifetime; forever = the workspace's lifetime (persists across container
    restarts via klangkd -- the cross-restart persistence is a follow-up; at the
    sidecar level it maps to a long in-memory TTL). Recorded on the
    egress_consent row.

  • Active-egress-decisions snapshot (egress_rules frame) (#2338). The
    consent-decider WebSocket now pushes an egress_rules snapshot on connect
    (and refreshes it after each verdict): the workspace's in-effect consent
    verdicts (allows and denies still within their duration) plus its static
    allow-list, for the upcoming rule-management view (#2335). The
    egress_consent.duration column is now read back and constrained by a DB
    CHECK to the documented duration values (mirroring DURATIONS).

  • Revoking a consent verdict (#2339). A decider can revoke an active
    allow/deny so its effect is immediate (not waiting for the duration/restart):
    klangkd pushes a drop_rule to the workspace's network sidecar, which drops
    the learned ACCEPT/REJECT rule for the host and acks back, and only then is
    the egress_consent row flipped to revoked (fail-closed -- a connected
    but unresponsive sidecar leaves the row enforced rather than falsely marking
    it revoked). A revoke decision + revoked_at/revoked_by audit columns are
    added (the decision CHECK is now generated from DECISIONS, like
    duration).

  • klangk consent-decide <workspace> (#2310). A live Textual client that
    connects to a workspace's consent-decider stream and shows its held egress
    requests (blocked destinations the network sidecar is holding for a
    verdict), with a countdown to auto-deny. Press a to allow (once) or d to
    deny; accepting lets that exact held connection proceed while a deny (or the
    countdown hitting zero) fails it. It pings every 15s to stay registered as
    the workspace's live decider and reconnects on drop; while no client is
    attached, held requests auto-deny (fail-closed). Requires terminal access to
    the workspace.

  • Embedded network sidecar image (#2301). The all-in-one host image
    (scripts/build-host-image.sh) now embeds the network sidecar image as a
    tarball and podman loads it on first startup, mirroring the workspace
    image. A default host-image deployment with FQDN egress enabled can start
    a workspace with allowed_domains without separately building or pulling
    the sidecar.

  • Egress consent recording (#2242). The network sidecar records every
    blocked destination to the egress_consent table: for static
    workspaces (the default) as denied with no human (decided_by NULL),
    immediately; for interactive workspaces as a pending request a
    human can allow/deny via the consent UI (#2244, not yet wired) before it
    auto-expires (egress_consent_timeout, default 30s; rate-limited per
    workspace via egress_consent_rate_limit, default 50). The sidecar
    consumes its own NFQUEUE (-j NFQUEUE --queue-num 5139; it is the netns
    owner with NET_ADMIN) and POSTs each blocked packet's destination to
    klangkd's consent endpoint (workspace-JWT-authenticated via Caddy's
    forward_auth); it also forwards denied DNS queries with their domain
    names (NFQUEUE only carries raw IPs). Static mode is now strictly better
    than the old silent-deny: it records denied attempts for audit/review.

  • Consent decider registry + /ws/consent-decider (#2308). Interactive
    egress consent is now runtime state: a workspace's blocked egress is held
    for a decision only while a consent decider is registered for it (or
    deploy-wide), over a new decider WebSocket (consent_decider_timeout,
    default 45s, reaps silent deciders). Behavior change: an interactive
    workspace with no decider registered now records blocked egress as a
    static denial instead of queuing it — it needs a live decider to queue.
    The decider client itself lands with #2310.

  • /ws/egress-sidecar + consent hold/resolve coordinator (#2311). The
    network sidecar's blocked-egress path gains a synchronous hold: a
    workspace's blocked egress is held in-flight (pending a human verdict) only
    while a consent decider is registered (#2308); with no decider it is denied
    at once as a static denial (no hold, no queued row). The sidecar connects
    over a new /ws/egress-sidecar WebSocket (workspace-JWT auth) and receives
    a verdict per blocked destination; the in-process coordinator fail-closes
    on timeout or shutdown (no leaked allow, no hung connection). Scope: this
    is the klangkd coordination half
    — the sidecar's kernel-level hold
    (suspending DNS queries, deferring NFQUEUE verdicts) + its WS client land in
    a follow-up, and decider fanout/verdict reception with #2244.

  • Decider WebSocket fanout + verdict reception (#2244). Held egress
    requests now reach a human: when the coordinator creates a hold it broadcasts
    an egress_request frame to every live decider for the workspace (and
    deploy-wide) over the /ws/consent-decider socket; a decider's verdict
    message is fed to resolve(), which records the decision, releases the held
    sidecar connection, and broadcasts egress_resolved so co-deciders drop it
    (first-decision-wins). A decider connecting mid-flight gets a snapshot of the
    workspace's current pending requests. The #2308 authz gap is closed: a
    workspace-scoped decider now needs terminal access to that workspace, a
    deploy-wide decider needs admin (else the socket is closed 4003 Forbidden),
    and a verdict is honored only for the decider's own workspace. The first
    consumer is the consent-decide client (#2310).

  • Sidecar consent gates the connection SYN (#2311, #2324). The network
    sidecar holds a non-allow-listed connection's SYN (NFQUEUE) pending the
    consent verdict instead of the DNS query: a denied name now resolves (the
    workspace gets the IP) and the first packet to that IP is queued -- allow
    learns the IP + lets it proceed, deny/timeout/WS-down fail-closes -- a
    denied connection gets a RST via a temporary REJECT (tcp-reset) rule so it
    fails at once (ECONNREFUSED), not after tcp_syn_retries ~127s (a static
    workspace or an unreachable klangkd behaves exactly as before; no hang).
    Gating the SYN gives the human the kernel's connect timeout
    (tcp_syn_retries ~127s) instead of a DNS resolver's <=30s getaddrinfo
    cap. KLANGKD_EGRESS_CONSENT_TIMEOUT and KLANGKNETWORK_EGRESS_HOLD_TIMEOUT
    defaults rise 30 -> 120s to use that window; SYN retransmits reuse the cached
    verdict so they don't each re-prompt. Distinct concurrent flows are held in
    parallel (#2329): the NFQUEUE consumer is loop-driven (get_fd + add_reader)

    • non-blocking (each held SYN is retained + handed to a verdict task), not a
      single blocking thread that serializes flows behind the first. New sidecar
      config: KLANGKNETWORK_EGRESS_VERDICT_CACHE_TTL (how long to reuse a SYN verdict,
      default 120s) and KLANGKNETWORK_EGRESS_REJECT_TTL (how long a deny keeps its
      REJECT tcp-reset rule so the connection fails fast, default 10s).
      KLANGKNETWORK_EGRESS_HOLD_LIMIT is removed (the DNS-path hold bound; the SYN
      path is bounded by the iptables rate-limit). The proxy is
      asyncio + the sidecar image gains the websockets dependency;
      the legacy fire-and-forget POST endpoint is superseded
      (recording happens on the WS path, removal tracked in #2318).
  • FQDN egress allow-list wildcards, per-domain port scoping, and learned-IP
    TTL (#2256).
    allowed_domains now accepts *.domain[:port] wildcards
    (subdomains only — distinct from a bare domain, which also matches the
    apex). host:port now scopes a learned IP to that single TCP port — a
    behavior change: previously a learned IP was reachable on all ports
    regardless of the spec's port, so an operator who relied on that (e.g.
    reaching :22 on an IP resolved under a :443 spec) will now see that
    traffic blocked; add an explicit spec for the extra port (a bare host
    keeps all-ports, unchanged). The sidecar's entrypoint now applies the same
    port scoping to CIDR specs like 10.0.0.0/8:443 (previously stripped).
    Resolved IPs are allow-listed only for the DNS response's TTL — the proxy
    re-resolves on each query and a background sweep removes the rule when the
    TTL elapses, so stale IPs no longer linger. See
    docs/features/egress-filtering.md.

  • klangk-build-nix-seed + klangk-load-nix-seed-btrfs — build/load the
    /nix seed from a wheel install (#2225).
    Two console scripts (shipped with
    pip install klangk) replace the former devenv-only shell scripts.
    klangk-build-nix-seed builds the shared nix seed dir — no source tree, no
    devenv: it bundles the seed Dockerfile in the wheel, drives the configured
    podman (KLANGKD_PODMAN_BIN), and writes <dir>/nix + <dir>/nix.conf
    (--update rebuilds in place, --no-cache forces fresh nix/devenv).
    klangk-load-nix-seed-btrfs <seed-tree> <btrfs-parent> loads the output into
    a btrfs subvolume for the btrfs-snapshot backend (the fuse backend points
    nix_seed.path at the dir directly). Both supersede
    scripts/build-nix-seed.sh + scripts/load-nix-seed-btrfs.sh (the tools
    work in dev too — the console scripts are in the devenv venv, with a
    source-tree Dockerfile fallback + podman resolved from the devenv PATH).

  • Per-workspace "Mount /nix dir" toggle in all create/edit surfaces
    (#2233).
    The per-workspace nix toggle is now exposed in the workspace
    edit panel (Flutter) and in the TUI create and edit screens, and is
    labeled "Mount /nix dir" everywhere (the Flutter create dialog's "Nix"
    checkbox is renamed to match). The toggle stays hidden when the server
    has no nix_seed backend configured; the underlying setting remains the
    boolean nix.

  • Interactive egress consent mode (#2239, #2240, #2241). Workspaces can
    now set egress_mode: "interactive" (API/CLI). In this mode the sidecar
    queues otherwise-blocked packets to its own NFQUEUE consumer (group 5139),
    which forwards each destination to klangkd for consent (#2242); the human
    decide/notify UI lands with #2244. Until then, interactive mode denies
    unmatched traffic exactly like static mode (no prompts; no security gap)
    while the monitor records the attempts. See
    docs/features/egress-filtering.md.

  • FQDN egress network sidecar + DNS proxy (#2250, #2253). New
    klangk-network-sidecar image (src/containers/network/) that runs a
    FQDN DNS proxy in a NET_ADMIN container sharing a filtered workspace's netns.
    It intercepts the workspace's DNS (nat REDIRECT of its configured resolvers),
    applies an allow-list, forwards allowed queries to a distinct upstream, and
    allow-lists the resolved IPs at runtime — solving DNS round-robin (a filtered
    workspace reaches an allowed domain on whatever IP it actually resolves). Part
    of the FQDN egress build (#2250); klangk lifecycle wiring is #2254.

  • nix_seed — per-workspace /nix with two backends (#2219, #2220). The
    per-workspace /nix config is now one block — nix_seed: {type, path}
    selecting a backend: btrfs-snapshot (a CoW snapshot of a seed btrfs
    subvolume) or fuse-overlayfs (the default; a fuse-overlayfs overlay of a
    plain-directory seed — works on any filesystem, no privileged helper; needs
    fuse-overlayfs + fusermount3 + /dev/fuse). Omit nix_seed to disable
    (nix is image-only). The fuse backend suits a bare-metal Linux host; it does
    not work where podman is nested (host-container, macOS — see #2221). See
    docs/features/nix.md.


This version's changelog exceeds GitHub's release-body limit and is truncated here. The complete changelog — including every entry for v2.0a1 — renders at https://mcdonc.github.io/klangk/changes/.