v2.0a1
Pre-releaseBreaking
-
KLANGKD_LISTEN/KLANGKD_EGRESS_LISTENvalues 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 bindinglisten:KLANGKD_PORT— move the
port toKLANGKD_PORT/KLANGKD_EGRESS_PORTon 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
likeeth0, which is a syntactically valid host name that passes
validation. -
Host-header validation changes URL derivation for name-accessed
deployments (#3276). A request'sHostheader now validates against
klangkd's own configuration (loopback, the armedKLANGKD_TLS_HOSTNAME
name, or theKLANGKD_LISTENIP-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 tolocalhost. Migration: deployments reached by an
identity klangkd does not configure (KLANGKD_LISTEN=0.0.0.0by DNS
name or non-literal address, a hostnamelisten, an outer proxy not
inKLANGKD_TRUSTED_PROXY_CIDRS) must setKLANGKD_HOSTING_HOSTNAME
or their links, container hosted-app URLs, and the OIDCredirect_uri
(login fails on the mismatch) all namelocalhost. Loopback,
automatic-TLS, and trusted-outer-proxy deployments are unaffected. See
HTTPS Hosting. -
Token-in-URL removal changes client contracts (#3201).
Anything connecting to/wsor/ws/consent-decidermust switch
from the?token=query param to thebearerWebSocket subprotocol
(the JWT rides the handshake'sSec-WebSocket-Protocolheader), and
any client of the verify endpoint mustPOSTthe 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_ROOTSunset (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. SetKLANGKD_ALLOWED_MOUNT_ROOTSto 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 quotaKLANGKD_VOLUME_QUOTA_PER_USERis now the
per-workspaceKLANGKD_VOLUME_QUOTA_PER_WORKSPACE
(volume_quota_per_workspace). -
Members can create workspaces by default (#3137). The seeded
create-workspaceAllow on/workspacesnow targets themembers
group (which every new user joins) in addition toadmins, 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/workspacesrows, so
existing deployments flip too; a deploy that wants the old
admin-only posture adds one explicit Deny formembers(or
Authenticated) ahead of the Allow in the ACL editor. See
ACL System. -
KLANGKD_PER_HANDLE_HOMEis 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 shapeKLANGKD_ALLOW_SUDOgot in #3047). While it is
false(the default), every workspace gets the shared
/home/klangkregardless of its storedper_handle_homevalue: a
storedtrue(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
setKLANGKD_PER_HANDLE_HOME=trueon 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_availableon/config), and
the CLI--per-handle-homeflag 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 storesallow_sudo: true(the Allow sudo
toggle, which now defaults to unchecked in the Flutter dialog/settings
panel and the TUI form, orklangk create --sudo/--sudoon edit);
an absent key means locked down, and atruecan never raise sudo
pastKLANGKD_ALLOW_SUDO=0. On upgrade, existing workspaces with
no storedallow_sudokey lose sudo at their next container start
(no bag migration runs) — opt them in withklangk edit <ws> --sudo
if they need it. See Container
packages. -
Volumes are an admin surface (#2993).
GET /volumesnow checks
the newview-volumespermission (the admin Volumes tab's listing
gate), whilePOST /volumesandDELETE /volumes/{name}keep
manage-volumes— both seeded Allow for theadminsgroup 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-volumesholders 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 lsships with
it, but external API consumers must read the envelope. -
Deploy-wide consent decider removed (#2976). The
/ws/consent-deciderhandshake without a?workspace=param is now
refused (HTTP 403): consent authority is strictly per-workspace
(egress-consenton/workspaces/{id}), andmanage-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. Theklangk shellpopup 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/imagesno longer returnsnix_available/
sudo_available— read them from the new authenticated-only
GET /api/v1/configfields of the same names. Hand-built clients
reading the toggles off the images response must switch to/config.
Migration0025also removes the retired seed's Deny Everyone row on
/images(it gated no route): authenticated users' effective
permissions on/imagesnow include theviewinherited 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(replacesadminon the workspace),
monitor-workspace,export-workspace,share-workspace,
share-advanced(replaceschange-acls), andfiles-view
(replacesfiles). 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 ofterminal:start-workspace,
stop-workspace, andrestart-workspaceare 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 checkmanage-volumesand
view-imageson 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)
checkssearch-userson/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 themanage-*
permission on the new first-class resource. The pre-existing
/groupsAllowcreateseed is migrated automatically (m0021); any
other custom/groupsrows are left for a manual re-grant. -
Hand-crafted
adminACEs stop matching split routes (#2940). ACLs
granting the literaladminpermission 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 grantedcontainer-eventsACEs while running
main, rename them tomanage-events(the feature was never in a
release). Likewise, pre-#2940/groupsAllowcreatedelegations
are now inert: group creation is gated bymanage-groupson
/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 namedadmins; upgrading renames theadmin
group in place (memberships and ACLs keep pointing at the same
group). If a group namedadminsalready 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"fromon_loginbefore the first post-upgrade
login — an unchanged hook auto-creates a permissionlessadmin
group and its membership diff-sync strips the user's syncedadmins
membership at their next login. -
enable_pingis removed; workspaces never holdCAP_NET_RAW(#2347).
TheKLANGKD_ENABLE_PINGsetting is gone (ignored on an existing config)
and every newly created workspace container launches with
--cap-drop net_raw; unprivilegedpinginside a workspace no longer
works (the setuid-ping /ping_group_range/setcapalternatives all
fail under rootless podman, #2045). Applies to containers started after
the upgrade; rebuild the workspace image to also shed the now-useless
setuidpingbinary baked into older ones. -
GET /api/v1/groupsnow 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 thegroupskey and
paginate withpage/page_size. -
KLANGKD_ALLOW_SUDOnow defaults to on (#2017). Passwordless sudo
inside workspace containers is granted by default; operators who want
the previous locked-down posture must setKLANGKD_ALLOW_SUDO=0
explicitly. The per-workspace lock-down (allow_sudo: falsein 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/workno 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'sWORKDIR. 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 (withhome/work/)
preserves its layout as-is. -
The default home layout is now shared (#2723).
KLANGKD_PER_HANDLE_HOME
(and theper_handle_homesetting) now defaults tofalse: new workspaces
share one/home/klangkinstead of per-user homes. Existing workspaces are
unaffected (migration backfilledtrue). 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
@klangkagent interaction, thepi --mode rpcchat-agent runtime, and the
chatfeature flag are gone.KLANGKD_FEATURES_ENABLE=chaton an existing
config is ignored with a startup warning — no operator action required.
Existingchat_messages/chat_mentionstables and stalechatACL rows
remain in the DB as inert leftovers (no destructive migration). Thechat
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 theservicetmux session are unchanged.
The agent home is still materialized at container create — now a plain
/home/klangkdirectory (no.users/{uid}symlink indirection; the
handle is fixed) populated from/etc/skel, without chat-agent Pi
config.klangk-setup-pistays as the generic per-user Pi setup. -
The agent user is
klangk(#2718). The agent's identity is fixed
(handleklangk, emailklangk@example.com) and matches the container
UNIX user / shared home.klangkis a reserved handle and the agent
row's handle/email can no longer be changed. The
KLANGKWS_FEATURE_CHAT_AGENT_HANDLE/EMAILfeature-config keys are
removed (stale settings are ignored). Migrationm0008rewrites the
agent row and relocates a human user who already held theklangk
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=interactiveworkspace (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_imageand a non-emptyKLANGKD_USERNS; if either is
missing it fails closed (refuses to start) instead of egressing
unrestricted. An interactive workspace withallow_sudoalso hadnet_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_*. OldKLANGK_*names are not accepted. Update
deploy manifests. -
"Plugin" → "feature" rename (#1658).
plugins.yaml→features.yaml,
plugins/→features/,update-plugins→update-features, etc. -
Feature config keys must start with
KLANGKWS_FEATURE_(#1662). Rename
KLANGKD_GITHUB_OAUTH_CLIENT_ID→KLANGKWS_FEATURE_GITHUB_OAUTH_CLIENT_ID,
KLANGKBUILD_BOING_SPEED→KLANGKWS_FEATURE_BOING_SPEED, etc. -
KLANGKD_CUSTOMIZE_DIRmoved to<config_dir>/custom(#1644). Move
contents from<state_dir>/customor setKLANGKD_CUSTOMIZE_DIRexplicitly. -
One
klangkdistribution (#1606).pip install klangkyields both
klangkd(server) andklangk(client).import klangk_backend→
import klangkd. TheklangkcPyPI distribution is retired. -
Default auth mode is
none(#1374). SetKLANGKD_AUTH_MODES=password
explicitly if you relied on the old default.noneis loopback-bound and
safe by construction. -
OIDC settings no longer change the auth mode (#1419). Set
KLANGKD_AUTH_MODES=oidc(orboth) explicitly. -
klangk invite→klangk admin invitations send(#1374).
Security
-
DPoP proof freshness and replay hardening (#3272). A proof
whoseiatis not a usable timestamp —NaN/Infinityliterals
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 /groupsno 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 (nosourceparameter) returns
source='workspace-role'rows only to callers holding
manage-groups; other authenticated callers get the manual-only
view. Explicitsource=manualandsource=workspace-rolefilters
work as before for every authenticated caller. -
Host-header poisoning of reset/verify links and the OIDC redirect
(#3276). A request'sHost(and theX-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: aworkspace.jsonmember beyond 1 MB is rejected
with 413 (previously it accumulated unbounded in memory — a small
crafted archive could OOM klangkd), and thehome/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_mbcap (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_ROOTSand 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/searchmatches filter text literally (#3280).
%and_in theqparameter are now treated as literal
characters (escapedLIKEpatterns), 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 theSec-WebSocket-Protocolheader instead of a
?token=query string; the OIDC callback redirects a one-time,
60-second code redeemed viaPOST /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;ipbinds each session to the network
it was established from (two IPv6 addresses inside one /64 count as
the same) andstrictalso requires the sameUser-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.revokestructured 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 carriescnf.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 explicitnone, 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 (default300seconds;0disables), 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
servedContent-Security-Policydrops'unsafe-inline'from
script-src: the inline<script>blocks of the built frontend's
index.htmlare allowed by SHA-256 hash tokens computed at
config-render time, andrequire-trusted-types-for 'script'now
blocks DOM-based XSS sinks (such asinnerHTML); a minimal default
Trusted Types policy inindex.htmlsanctions only relative,
same-origin, and same-originblob: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 —importScriptsis 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 browsersessionStorageinstead oflocalStorage, 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 fromlocalStorage. 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-readable403 step_up_requireduntil the session's owner
confirms their password viaPOST /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;15is 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-deciderconnection 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'sexp
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-passwordnow blocklists every active session
token and closes their WebSocket connections, forcing re-login on
all devices. -
Content-Security-Policyon 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,boingballleft the default feature set and the
beep/boingballfeatures plus the file viewer's monospace font were
made first-party: Web Audio calls use typed interop instead of JSeval,
and Roboto Mono is self-hosted instead of fetched from fonts.gstatic.com.
Unpinned deployments lose the boingball overlay on upgrade (it left the
defaultslist); keep it by pinningKLANGKD_FEATURES_ENABLEto a list
that includesboingball. -
HMAC integrity tags for audit records (#3174).
WithKLANGKD_AUDIT_HMAC_KEYset, everycontainer_eventsand
egress_consentrow 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 fromKLANGKD_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 soRestart=on-failuresupervisors restart the node. -
FIPS mode now keeps JWT signing inside the validated module
(#3175). The FIPS host image rebuildscryptographyfrom source
against the distro OpenSSL (the PyPI wheel bundles a private OpenSSL
that bypasses the FIPS provider), andKLANGKD_FIPS_MODEverifies at
startup that python-jose binds thecryptographybackend 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-passwordand
POST /api/v1/auth/resend-verificationare 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-passwordnow 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-passwordnow 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). Withjoin-workspaceas the connect gate (#2975), a
join-only member could reach frames whose only protection was the
old terminal-checked handshake:ssh_agent_startspawned 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 plainPermission deniederror frame (deliberately not the
forbiddencode, which #2891 reserves for connect-level refusals —
a stamped sub-action denial would dead-end the whole workspace
page) unless the caller holdscode-in-isolation(window frames)
or eithercode-in-isolationorexec-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/inspectargv 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/volumesand
DELETE /api/v1/volumes/{name}now reject names that are not
podman-safe — they must start with an alphanumeric character,
continue witha-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,--allcould remove every unused volume on the
host). -
Volume-create conflict check no longer leaks foreign volume names
(#2973).POST /api/v1/volumesanswered 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-proxyendpoints now require a workspace JWT (#2959). The
backend validates the workspace token itself, mirroring the egress
proxy'sforward_authcheck. 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 morecurl | sh/
curl | tarpipes), 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-delegateand/api/v1/browser-delegate/stream
now verify that the submittedbrowser_idwas 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
monitorpermission gates health/status reception (#2783,
#1714). Observing a workspace's health no longer requires
terminal:GET /workspaces/{id}/statusand the member-scoped
container_status/service_health/workspace_evicted
WebSocket frames now check the dedicatedmonitorpermission.
Every role and share that grantsterminalalso grantsmonitor
(existing deployments are backfilled by migration 0016), and
monitorcan be granted alone for monitoring-only members who
should observe health without exec/attach access. -
New
egress-consentpermission 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 requireegress-consentinstead
ofterminal— 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 onlyterminal— whether
via a custom ACL or the simple Sharing tab (its grants do not include
egress-consent) — must be grantedegress-consentexplicitly 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), andworkspace_evictedframes were
fanned out to every authenticated connection, letting any connected
client enumerate every workspace's id, running state, health, and
the boundedhealth_messagetail of another tenant's service
output; they are now delivered only to users holdingmonitoron
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 viaklangk monitornow sees
only their own workspaces — intended, and part of the fix. -
exec-and-syncpermission gates one-shot command execution and
klangk sync(#2706, #2712). The one-shot exec channel —klangk exec, and the rsync transportklangk syncandklangk sandbox
ride on — now requires the newexec-and-syncpermission on the
workspace, enforced server-side atexec_start. Both sync directions
are covered by the same gate: a member withoutexec-and-synccannot
run one-shot commands or sync in either direction. Isolated terminals
still usecode-in-isolationand are unaffected. Coders and
collaborators keepexec-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-isolationmust addexec-and-syncexplicitly to keep
one-shot exec working for those principals.klangk exec/klangk syncreport 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 requiresshare-terminals(owners and
collaborators by default) and joining another member's shared
terminal requiresspectate-on-shared-terminals(all roles); a
joiner withoutcode-in-shared-terminalsorshare-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(andklangk export) now
requires theexportpermission on/workspaces/{id}instead of the
adminpermission on/admin: owners keep exporting their own
workspaces (the owner wildcard ACE and the seededowners-<id>role
group both coverexport), while admins no longer bulk-export
workspaces they hold no grant on. A DenyexportACE on a workspace
resource, positioned ahead of the wildcard allows, revokes export per
workspace (rewritable by anyone holdingsharethere). See
Export & Import. -
Group creation restricted to administrators (#2770). The default
ACL no longer grantscreateon/groupsto every authenticated
user; it goes to theadmingroup instead, matching workspace
creation (#2569). Existing deployments whose/groupsstill carries
exactly the seeded ACE are migrated automatically; if your/groups
entries differ (deliberately loosened or customized), remove the
Allowcreate→ Authenticated users entry via the ACL editor. To
re-open group creation, add an Allow ACE forcreateon/groups
targeting themembersgroup. -
Workspace creation restricted to administrators (#2569).
POST /workspaces,POST /workspaces/import, and workspace duplication
now require thecreatepermission on the/workspacescollection
resource, which defaults to theadmingroup only. Non-admin users
see the create button hidden and receive 403 if they call the API
directly. A new built-inmembersgroup 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 forcreateon/workspacestargeting themembers
group via the ACL editor. -
FIPS host container image + containerized boot gate (#2628). New
src/containers/host/Dockerfile.fipslayers 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 withklangk:build-fips-host-image, or pull
the CI-built image from GHCR (klangk-host-fips). With
KLANGKD_FIPS_MODEon, 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 theredirect_uristored 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 onlystate,verifier, andcli_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/configadvertises 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_redirectuserinfo bypass (#2571). The localhost-only
guard on the CLI login redirect used prefix matching, so a crafted
cli_redirectlikehttp://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 plainhttptolocalhost
or127.0.0.1with a port and no userinfo; anything else falls back to
the web flow. -
/files/contentnow requiresfiles-download(#2713). The file
viewer's text-reader endpoint
(GET /api/v1/workspaces/{id}/files/content) is gated by the
files-downloadpermission like/files/download(#2705), closing
the remaining scripted bulk-read avenue for members withfiles
alone. Operators who relied on text viewing without download must
also grantfiles-download(or withholdfilesentirely). The web
file viewer degrades gracefully: listings and metadata stay visible
and the content pane reports the denial. See
ACLs.
Added
-
hostandinstancefields on JSON log records (#3330). Every
JSON log line (console inKLANGKD_LOG_FORMAT=jsonand the
KLANGKD_LOG_FILEsink) now carrieshost(the emitting machine's
hostname) andinstance(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_dirchange applies after the restart the reload warning already
asks for. -
Clear-Site-Data: "storage"on logout (#3328). The
POST /api/v1/auth/logoutresponse 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_HOSTNAMEset) 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: oneapp.startrow
per boot (build version, pid, listener) and oneapp.stoprow 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 ownapp.reloadrow
— 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-inKLANGKD_AUDIT_HMAC_KEYtagging and the
audit-record forwarder, listed byGET /api/v1/events/auditnext 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 /healthdegraded 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 todegradedand 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 restoresok. -
Disk watchdog monitors
state_dir(#3310). The resource
watchdog's disk-capacity check now measures the filesystem holding
KLANGKD_STATE_DIRin addition to the data directory, so a
split-mount deployment (state dir on the OS disk, data dir on its
own volume) getsresource.disk.warn/resource.disk.critical
/resource.disk.recoveredfor 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
viapodman machine ssh— containers live in that VM) and CPU
pressure (PSIsome 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-surfaceKLANGKD_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/execveathost-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.vpidmatches tmux#{pane_pid};container.idenriches 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 optionalKLANGKD_AUDIT_FORWARD_HEADERauth
header) and/orKLANGKD_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
/auditunderforwarding. 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_eventsrow minted from an HTTP request now records the
request's method andRefererheader alongside the existing client
IP and user agent (SV-222447), andGET /api/v1/events/auditserves
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, andKLANGKD_DISK_WATCHDOG_PATHSevery 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 oneaudit.failureper table, and
GET /auditreports 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_eventsstream:file.download,file.upload,
file.write, andfile.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 underKLANGKD_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/eventsreturns one time-ordered stream merged across
all three audit tables (identity/privilege, container lifecycle,
egress consent), each row naming its origin table insourceand
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
samemanage-eventspermission. 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 oneuser.disablerow per
disabled account (via=inactivityin the detail) — the audit
stream's firstuser.disablerows, since an admin's disable/enable
toggle records inside itsuser.updaterow. 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_EVENTSnarrows 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/.crtcerts are
trusted, via a staged copy (<state_dir>/ssl/approved) consumed by
the backend bundle and workspace containers. Certs dropped into
<KLANGKD_CUSTOMIZE_DIR>/certsare 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
fontFallbackBaseUrlat 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.pyafter 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. Samemanage-eventsgrant as
the container history — no new permission wiring. -
Structured audit stream for identity and privilege actions
(#3205). A newaudit_eventstable 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_KEYtagging; writes are best-effort and never
fail the action they annotate. Queryable bymanage-eventsholders
viaGET /api/v1/events/audit(paged, filterable by event, actor,
target). Bounded byKLANGKD_AUDIT_EVENTS_RETENTION_DAYS(default
365) andKLANGKD_AUDIT_EVENTS_ROW_CAP(default100000,
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). SettingKLANGKD_TLS_HOSTNAMEserves
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 defaultacmeissuer the
certificate comes from Let's Encrypt/ZeroSSL (public FQDN, reachable
ports 80/443,KLANGKD_ACME_EMAILfor expiry notices);
tls-issuer: internalself-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(default10;
0turns the split off). Armed tokens are capped at the window.
0on 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, orjson) switches the console log stream to one
JSON object per line (timestamp,level,logger,message,exc_info
when present);KLANGKD_LOG_FILEadditionally writes that JSON stream to a
file while the console keeps its own format — stdout can stay readable text
while the file feeds rsyslogimfile/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) andKLANGKD_LOG_FILE_ROTATE
(hourly/daily/weekly/monthly, UTC boundaries) rotate the sink to
numeric-suffix backups, retention viaKLANGKD_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 onPOST /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/configso 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,
/wsupgrades,/hosted/*, and/healthnever 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-readable403; 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
to0(disabled); the recommended hardening values are24hours /
60days. 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_passwordflag; the flagged session may do nothing except
change its password (API requests 403Password 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 theircontainer_eventsaudit 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/auditendpoint (public,
like/health) reportswrite_failures(an in-memory counter
bumped on every audit-write failure, best-effort paths included;
zeroed on restart) andfail_closedso assessors can verify the
mode. Reloadable on SIGHUP. -
All five container images now publish on a release tag (#3140).
PushingvX.Y.Zpublishesklangk-host,klangk-host-fips,
klangk-workspace,klangk-workspace-fips, and the newly pullable
klangk-network-sidecarto GHCR under that tag, built from the tagged
commit. Versioned tags only: the floating:lateststays owned by the
continuous builds. See
Building Images. -
/healthnow 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-idand the podmanklangk.instancelabels). 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_adminflag on/my-permissions(#2995). The response now
carries an explicit instance-admin flag derived fromadmins-group
membership (the CLI'sstatusand the web app's admin gating read
it). The/adminACL tree is retired with it: no rows are seeded,
/adminno longer appears in the permission map or the ACL browser,
and migration m0027 deletes any stored/adminrows (they answered
no check). Because the group's name is now load-bearing, the
adminsgroup 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. -
workspacefilter onGET /api/v1/events(#3006). The Admin →
Events filter now accepts a workspace name as well as a workspace id:
the newworkspacequery param matches an exact id or a workspace-name
substring. The legacyworkspace_idparam 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-workspacepermission (#2975). Theworkspace_connect
gate — opening a workspace at all — now checksjoin-workspace
instead ofterminal.terminalkeeps 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
storedterminalACE — Allow and Deny, on any resource the ACL
ancestor walk consults — to ajoin-workspacesibling 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) grantjoin-workspacealongsideterminal. -
README release badge (#2981). The README now carries a release
badge showing the latestv*tag, driven by GitHub Releases —
no manual updates needed. -
Versioned documentation (#687). Each release tag now deploys its
docs as a versioned subdirectory of thegh-pagesbranch (managed by
mike, with the zensical version selector), instead of overwriting the
whole site. The docs root redirects to thelatestversion. 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, andidle_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 deprecatedproxy_portalias accepts a bare integer
too. Env vars, quoted strings, andfile:/cmd:indirection are
unaffected. -
KLANGKWS_FEATURE_OAUTH_PROVIDERS(#432). JSON list of OAuth
device-flow providers (host,client_id,device_code_url,
token_url, optionalscope/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_IDshorthand coversgitlab.com
the way the existing GitHub one does. The browser dialog now names the
provider host ("Sign in to gitlab.com"), onlyhttpsverification pages
are auto-opened, and malformed provider responses fall back to the PAT
dialog instead of hanging it. See
GitHub Authentication. -
Granular
/admintab permissions (#2940). The admin endpoints
split off the monolithicadmingate onto one permission per tab:
manage-users(Users),manage-invitations(Invitations),
manage-groups(Groups),manage-server-schedule(Server), and
manage-events(Events; renamed fromcontainer-events). Admins are
unaffected — the seeded/admin*wildcard covers every name — and
a whole tab can now be delegated to a non-admin via anAllowACE on
its sub-resource.manage-acls(Access Control browser) is
root-equivalent: it can rewrite ACLs on any resource including
/adminand/, so it is granted only to administrators. See
ACLs. -
Container events history API + admin Events tab (#2923). New
GET /api/v1/eventsendpoint pages through the
container_eventsaudit table (#2915) newest-first, with an optional
workspace_idfilter 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-eventspermission over/admin/container-events(renamed
fromcontainer-eventsin #2940 before any release): admins
hold it via the/adminwildcard, 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 newcontainer_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-causedsidecar_start/
sidecar_stoprows; workspace rows carry the sidecar container id
asnetwork_namespacefor 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 theirklangk.workspacelabel). 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 thecontainer_eventsaudit 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 to0to disable that
bound. Reloadable on SIGHUP (applies on the next sweep). -
change-aclspermission (#2764). Raw ACL editing is now gated
on a dedicated resource-level permission instead ofshare:
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 anowners-member)
requirechange-acls;PUT /api/v1/acl/resourceadditionally
requires it when the target is an individual workspace. The simple
sharing surface (member and group shares with the fixed permission
set) stays onshare. Owners are covered by their*wildcard, and
migration 0017 backfillschange-aclsonto existing effective
shareholders, so workspace-side behavior is unchanged for them.
Integrators callingPUT /admin/acl/resourceagainst individual
workspaces must now holdchange-aclsthere (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, andtest_modenow
accept a baretrue/falsein 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_staton
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 (default1g). 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 theclassification_bannerfield 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 malformedKLANGKD_CLASSIFICATION_BANNERaborts
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 totrue; setfalseto 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 staleklangk-browser-id, but their bridge
POSTs get the same 403), and advertise
browser_delegate_enabled: falsevia/api/v1/configso the web UI
stops answering bridge requests. Reloadable on SIGHUP. See
Browser Bridge. -
files-writepermission (#2705). The mutating files endpoints —
upload (POST …/files/upload), rename (POST …/files/rename), and
delete (DELETE …/files) — now require the newfiles-write
permission in addition tofiles. New shares (members, groups,
coder/collaborator roles) grant it; migrationm0012grants it
alongside every Allowfiles-downloadgrant, 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-downloadpermission (#2705). The workspace file-download
endpoint (GET /api/v1/workspaces/{id}/files/download) now requires
the newfiles-downloadpermission in addition tofiles, 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
filesgrants 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) whoseon_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 tosource=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
sourcemarker and filtering (#2750). Groups now carry a
sourcecolumn:manualfor human-managed groups,
workspace-rolefor the four role groups seeded per workspace.
GET /api/v1/groupsandGET /api/v1/admin/groupsaccept asource
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_sudoin the workspace
settings bag (set withklangk 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 withKLANGKD_ALLOW_SUDOon.
KLANGKD_ALLOW_SUDOstays 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_availableon/api/v1/images). -
Workspace export/import preserves the home layout (#2722).
workspace.json
now carriesper_handle_home, and import honors the archive's layout even
when the server'sKLANGKD_PER_HANDLE_HOMEdefault 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 asdefault_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
servicetmux session now runs withHOME=/home/klangkpinned as a
constant under both home layouts, and/home/klangkis 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/.profilereach 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_HOMEremains baked as the
constant/home/klangk, so sandboxsetup.shscripts using
export HOME="${KLANGKWS_AGENT_HOME}"keep working (a no-op on
shared-home workspaces). -
per_handle_homenow selects the home layout at runtime (#2720).
A workspace created withper_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 theservicetmux 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 newper_handle_homefield onPOST /workspaces
(and editable later withPUT /workspaces/{id}— a flip applies from
the next connect/start); exposed inGET /workspacespayloads;
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 liveserver_schedulesnapshot, 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 loginstatus 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 spawnsklangk shellin 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--holdkeeps 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 shellnow 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 …sockinto 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/schedulewith
{action: "stop" | "recycle", at | in_seconds}; list/cancel via
GET/DELETEon the same resource). Schedules persist in the DB
acrossklangkdrestarts 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 aserver: stop at 23:00 (in 1h 12m)status line in the
TUI. See Server Scheduling. -
EX_CONFIGexit status 78 for deterministic config errors (#2666).
Whenklangkdrefuses to boot over bad configuration — e.g. a
KLANGKD_DEFAULT_PASSWORDthat violates the password policy, password
mode without a staged password,auth_modes: noneon 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 ahost_shutdownWebSocket event (so clients render -
Graceful stop on SIGTERM/SIGINT (#2527, #2664). TERM/INT shutdown
now broadcasts ahost_shutdownWebSocket event (so clients render
"server went away" instead of reconnect-looping), refuses new
workspace starts, waits up toKLANGKD_QUIESCE_TIMEOUTseconds
(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_stoppedwith reasonhost 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 surfacehost_shutdown/server_recycle/host_startedas
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_TIMEOUTseconds (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_startones return).
Clients getserver_recycleevents with aphasefield and a final
host_startedbroadcast; 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=yesapproved 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 newklangk:build-fips-imagedevenv task builds the FIPS
workspace image variant. See
FIPS 140-3 Mode. -
Host memory-pressure eviction (#2526). When memory availability
stays belowKLANGKD_MEMORY_EVICTION_THRESHOLD_PERCENT(default 10%)
forKLANGKD_MEMORY_EVICTION_SUSTAIN_POLLSpolls (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 aworkspace_evictedWS event. Availability is
measured platform-aware:/proc/meminfoon Linux (plus the cgroup
limit inside memory-limited containers, e.g. Docker-m), and
vm_stat/sysctlon macOS. Workspaces with live clients and
workspaces pinned never-stop (idle_timeout0, e.g. auto-started
boot services) are never chosen while an idle one exists; on by
default — disable withKLANGKD_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. SetKLANGKD_CONTAINER_RESTART_ENABLED=trueto also auto-restart
such workspaces after an exponential backoff (default 5s → 10s → 20s,
capped at 60s;KLANGKD_CONTAINER_RESTART_BACKOFF_SECONDS), with at
mostKLANGKD_CONTAINER_RESTART_MAX_RETRIES(default5) attempts —
exhaustion leaves a visiblecrash-loopstate on
GET /workspaces/<id>/statusinstead 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-verificationnow count toward the login lockout
(KLANGKD_LOGIN_LOCKOUT_*), keyed like login on the account's email.
A locked-out account gets429there 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
(default35days;0disables) are disabled by an hourly sweep.
Login, token refresh, and authenticated requests then fail with
403 Account disabledand 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 alast_login_attimestamp on the user.
GET /auth/mereports it, the TUI main-screen status bar shows it,
andklangk account showprints 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}/sessionsendpoint 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 (default0= 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_requirementsin/api/v1/configfor inline validation in
the web UI and CLI. -
FIPS 140-3 workspace image (#2570, #2577). New
src/containers/workspace/Dockerfile.fipsvariant 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). Theegress_consenttable is now bounded on long-lived deploys:
a retention window (default 30 days;0disables) deletes terminal rows
older than it, and a per-workspace row cap (default 2000;0disables)
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 thetilrestartreap 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_GATEforwarding (#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'srejected_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-decideTUI control (#2332). Requires the same
share-terminalspermission 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 shellconsent-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 atmux 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-aprefix, no status bar, mouse passes through to the container tmux).
C-a preopens the popup,qhides 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-popupis passed. -
klangkd doctortmux 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-decidepersistent popup role (#2383). The decider
accepts internal--popup-socket/--popup-sessionoptions (set by the
shell-layer wrapper) for its upcoming persistent role inside a hidden tmux
session: in that roleqhides the popup viewer (detaching it, leaving the
decider registered) andQconfirms a real quit. Standaloneqstill
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 reusesKLANGKNETWORK_EGRESS_ACTIVITY_GATE(default 60s) as the
sample cadence. -
KLANGKNETWORK_EGRESS_UPSTREAM— operator-pinnable sidecar DNS upstream (#2424).
When set inklangkd'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 existingKLANGKNETWORK_EGRESS_MIN_TTL/SWEEP_INTERVALforwarding. -
KLANGKD_CONTAINER_TMP_SIZE+settings.tmp_size(#2378). The
per-workspace/tmptmpfs size is now configurable (e.g.2g,512m);
the deploy default stays2g(the prior hardcoded value), so existing
installs are unchanged. Set the env var empty to mount/tmpwith 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 alongsidestatic(default-deny) andinteractive
(consent-gated). Anallowworkspace 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 withstatic). 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 sandboxnow createsallow-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, defaultinteractive), 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
allowmode itself landed in #2406. -
rejected_domainsin the TUI + Flutter workspace dialogs (#2386). The
static deny-list is now editable end to end, mirroringallowed_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 theklangk create/edit --rejectCLI flags. The shared
validator rejects CIDR specs forrejected_domainsup front (NXDOMAIN is
name-level), matching the API. The list page also badges a workspace whose
rejected_domainsis set but netfilter is disabled. -
rejected_domainsworkspace setting + sidecar enforcement (#2367).
The deny counterpart toallowed_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 (noallowed_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); theforeverdeny 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 TUIconsent-deciderules screen. It lists the static allow-list,
active consent allows (withexpires in 5m/until restart/forever
labels), and active denies (with remaining window), all live off the
existingegress_rulesstream. 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. -
foreveregress-consent deny persists across restarts (#2369). The deny
counterpart of the forever-allow: adenywithduration=foreverappends
the host to the workspace'srejected_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
theconsent-decideTUI (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_domainsrules and existingegress_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 refreshedegress_rulesframe carries the
livepausedwindow to every decider. -
reject_listin theegress_rulesframe (#2370, #2340). The read-only
rules view (ConsentCoordinator.rules_frame) now surfaces the workspace's
rejected_domainsalongside the existingallow_list, so deciders see the
static deny-list in the rule-management screen. -
foreveregress-consent allow persists across connections and restarts
(#2368, #2372). An allow withduration=foreverallow-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
consentedhost:porttoallowed_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. Theforeverdeny counterpart is a follow-up
(#2369). -
Static egress mode refuses consent deciders (#2394). A workspace-scoped
consent decider connecting to a workspace withegress_mode = "static"is
now refused at registration with a4003 Forbiddenclose
(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_interactivegate remains as
defense-in-depth. -
Revoke action in
consent-decide(#2341). On the rules screen
(r), focus an active consent allow/deny row and pressxto 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)
flashesrevoke failed — still in effectand 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). Pressrin the
consent-decideTUI 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
asexpires 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/Escreturns to the queue. The
WebSocket worker stays connected across the switch, so holds keep arriving
and the list updates live from theegress_rulesframe (#2338). Revoking a
row is a separate follow-up (#2339/#2341). -
Interactive egress-consent banner in the web UI (#2246). Workspaces
ininteractiveegress 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 theconsent-decide
TUI; verdicts go live over the/ws/consent-deciderstream. 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 aduration(once|5m|15m|1h|1d|1w|
restart|forever, defaultrestart). Theconsent-decideTUI 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_consentrow. -
Active-egress-decisions snapshot (
egress_rulesframe) (#2338). The
consent-decider WebSocket now pushes anegress_rulessnapshot 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.durationcolumn is now read back and constrained by a DB
CHECKto the documented duration values (mirroringDURATIONS). -
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 adrop_ruleto the workspace's network sidecar, which drops
the learned ACCEPT/REJECT rule for the host and acks back, and only then is
theegress_consentrow flipped torevoked(fail-closed -- a connected
but unresponsive sidecar leaves the row enforced rather than falsely marking
it revoked). Arevokedecision +revoked_at/revoked_byaudit columns are
added (thedecisionCHECKis now generated fromDECISIONS, 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. Pressato allow (once) ordto
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 andpodman loads it on first startup, mirroring the workspace
image. A default host-image deployment with FQDN egress enabled can start
a workspace withallowed_domainswithout separately building or pulling
the sidecar. -
Egress consent recording (#2242). The network sidecar records every
blocked destination to theegress_consenttable: for static
workspaces (the default) asdeniedwith no human (decided_byNULL),
immediately; for interactive workspaces as apendingrequest 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 viaegress_consent_rate_limit, default 50). The sidecar
consumes its own NFQUEUE (-j NFQUEUE --queue-num 5139; it is the netns
owner withNET_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: aninteractive
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-sidecarWebSocket (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
anegress_requestframe to every live decider for the workspace (and
deploy-wide) over the/ws/consent-decidersocket; a decider'sverdict
message is fed toresolve(), which records the decision, releases the held
sidecar connection, and broadcastsegress_resolvedso 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 needsterminalaccess to that workspace, a
deploy-wide decider needs admin (else the socket is closed4003 Forbidden),
and a verdict is honored only for the decider's own workspace. The first
consumer is theconsent-decideclient (#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 <=30sgetaddrinfo
cap.KLANGKD_EGRESS_CONSENT_TIMEOUTandKLANGKNETWORK_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) andKLANGKNETWORK_EGRESS_REJECT_TTL(how long a deny keeps its
REJECT tcp-reset rule so the connection fails fast, default 10s).
KLANGKNETWORK_EGRESS_HOLD_LIMITis removed (the DNS-path hold bound; the SYN
path is bounded by the iptables rate-limit). The proxy is
asyncio + the sidecar image gains thewebsocketsdependency;
the legacy fire-and-forget POST endpoint is superseded
(recording happens on the WS path, removal tracked in #2318).
- non-blocking (each held SYN is retained + handed to a verdict task), not a
-
FQDN egress allow-list wildcards, per-domain port scoping, and learned-IP
TTL (#2256).allowed_domainsnow accepts*.domain[:port]wildcards
(subdomains only — distinct from a baredomain, which also matches the
apex).host:portnow 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:22on an IP resolved under a:443spec) will now see that
traffic blocked; add an explicit spec for the extra port (a barehost
keeps all-ports, unchanged). The sidecar's entrypoint now applies the same
port scoping to CIDR specs like10.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
/nixseed from a wheel install (#2225). Two console scripts (shipped with
pip install klangk) replace the former devenv-only shell scripts.
klangk-build-nix-seedbuilds 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
(--updaterebuilds in place,--no-cacheforces fresh nix/devenv).
klangk-load-nix-seed-btrfs <seed-tree> <btrfs-parent>loads the output into
a btrfs subvolume for thebtrfs-snapshotbackend (the fuse backend points
nix_seed.pathat 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 nonix_seedbackend configured; the underlying setting remains the
booleannix. -
Interactive egress consent mode (#2239, #2240, #2241). Workspaces can
now setegress_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-sidecarimage (src/containers/network/) that runs a
FQDN DNS proxy in aNET_ADMINcontainer 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/nixwith two backends (#2219, #2220). The
per-workspace/nixconfig is now one block —nix_seed: {type, path}—
selecting a backend:btrfs-snapshot(a CoW snapshot of a seed btrfs
subvolume) orfuse-overlayfs(the default; afuse-overlayfsoverlay of a
plain-directory seed — works on any filesystem, no privileged helper; needs
fuse-overlayfs+fusermount3+/dev/fuse). Omitnix_seedto 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/.