v0.6.0
Pre-releaseAdded
-
Members onboard their own workspaces from the console. The Workspaces
screen and the New-Run wizard's Add-a-workspace dialog now work for a member
session against the member-scoped routes: create and scan your OWN
workspaces, with the admin-set mount boundary shown in place — the daemon
tells the console the member's local-dir root (member_local_dir_rooton
GET /me), and the path field says so. The writable checkbox does not
exist for members (the request never carrieswritable; the server-side
allowlist is the boundary either way). The mock at
docs/design/ui-batch2-mock.mdis the design source of truth for every
string. -
Workspace reassignment (offboarding).
POST /workspaces/{id}/reassign
(admin-only) moves a member-owned workspace to operator ownership
(owned_by=''), auditedworkspace.reassignwithfrom_owner. Members get
the same constant 403 as every admin-only workspace route — deliberately
more existence-blind than a 404 split. -
No impersonation in the audit trail. When an admin acts on a
member-owned workspace, the audit actor stays the ADMIN's identity, and the
event carriesworkspace_ownernaming the member — cross-user admin access
is queryable (?actor=plusworkspace_owner≠ actor), pinned by a guard
test at every workspace write site. -
A failed run says why, in the console. The run page's FAILED state
renders thefailure_hintthe backend has stamped since migration0044—
bare server text beside the state badge, nothing when there is no hint.
The unlisted-host copy in the New-Run wizard now describes what the proxy
actually does (refused-and-raised, approve once, retry gets through), and
the egress panel names the agent CLI's telemetry endpoint for what it is. -
Desktop install lane.
deploy/desktop/gainsinstall.sh(managed
dir, per-device age key minted at install — never distributed via MDM),
com.wardyn.daemon.plist(launchd), andwardyn-desktop.sh
(docker compose --env-file … -p wardyn-desktop up -d --no-build --pull always, healthz wait, idempotentwardyn site-config apply).
wardyn_pick_docker_hostnow recognizes a Colima socket the way it does
Rancher's — without it, confinement silently collapses on Colima Macs. -
Desktop honesty gates.
scripts/test-desktop-profile.shjoins
make test-scripts(which CI runs), and adesktop-envelopeCI job boots
compose with the example profile and asserts the managed policy file is
exactly what the daemon serves, an unpoliced run resolves to the ceiling,
and profile synthesis stays clamped. The one scripted macOS smoke run is an
operator step documented indocs/DESKTOP.md— run once, paste output; CI
does not cover it and the doc says so. -
ROADMAP truth. The desktop slice moves to 0.6; interactive
tool-approvals→console is marked deferred to 0.7 with its reasons (the
prompt-tool contract is non-interactive-only, the hook alternative fails
open on timeout, and a self-service member who could approve can already
attach). -
Per-user API tokens. A signed-in human mints
wdn_…bearer tokens for
themselves (POST /me/tokensreturns the plaintext exactly once;GET/
DELETE /me/tokens); an admin can list and revoke anyone's (/tokens).
Only the SHA-256 is stored (migration0045). A token authenticates as
the human who minted it — it publishes the same context the OIDC session
does, so grants, RBAC and ownership bind identically and a member's token
can never reach an admin route. Auditedtoken.create/token.revoke. -
wardynd -rotate-age-key <path>: age-key rotation as a maintenance
mode. With the daemon stopped, mints a new age identity, re-encrypts every
stored secret in one transaction (any row that fails to decrypt aborts the
whole rotation), swaps the key file atomically and exits. ThewardynCLI
never sees the key. Auditedsecret.rekey(count only, no names). -
Hash-chained audit log (migration
0047). Every new event carries
prev_hash/row_hash(SHA-256 over the previous hash and the row's
immutable fields, computed by Postgres under a transaction-scoped advisory
lock so chain order equals commit order). The head hash rides the audit-sink
stream so an external SIEM can detect truncation;GET /audit/chain/verify
(admin) walks the chain and reports the first break. Tamper-evident, not
tamper-proof — a database owner can rewrite the whole chain; the threat
model says so. -
env_secretgrant kind. Injects a named stored secret as a sandbox
environment variable at dispatch. Resident for the run's lifetime and not
revocable mid-run — its own threat-model row — so it is admin-only unless
WARDYN_ALLOW_MEMBER_ENV_SECRETopens it to members. The grant-pairing
table is now closed: an unknown grant kind is refused instead of falling
through unclamped. -
git_patper-run lease. Agit_patapproval decided with
decision_scope=runre-mints for the rest of that run under the one
decision; mints are stampedleaseincredential.mint. The scope is
compared as stored — a legacy approval never silently becomes a lease. -
Second-human egress approval.
WARDYN_EGRESS_SECOND_HUMAN=1refuses an
egress decision by the run's own creator (authz.denied,
reason: second_human_required). The shared admin token has no per-human
identity and bypasses the rule — that bypass is audited
(approval.second_human.bypass) and documented as break-glass, not hidden. -
auth.failedaudit event. Admin-token 401s and rejected OIDC session
cookies used to fail silently; they now emit a content-freeauth.failed
(reason, source IP, path) behind a process-local token bucket so a scanner
cannot flood the append-only log. -
WARDYN_AUDIT_SOURCEstamps a staticsourcefield on every event a
sink serializes — one SIEM index can tell instances apart. Sink payloads
only, never Postgres. OPERATIONS.md gains Splunk HEC / generic-webhook
recipes. -
WARDYN_REQUIRE_OPERATOR_SET_EGRESS(default off) applies the
scan-seeded provenance guard that already protected secrets to egress
domains: a run may not carry egress the operator never set. -
Setup status grades the permissioning posture — the fail-open
enforcement switches are scored as a check, informational and non-blocking. -
If-MatchonPUT /permissions/enforcementand site-config apply.
Both whole-replace surfaces return anETag; a staleIf-Matchis refused
with 412 before the write reaches the store. Omitting the header keeps
today's behaviour. -
SSH admin override is bounded-stale, not permanent (migration
0046).
Every OIDC login re-stampsrole/role_checked_aton the principal's
registered keys; the gateway refuses the override once the stamp is older
thanWARDYN_SSH_ROLE_TTL(default 24h). Keys registered before 0.6 carry
no stamp and never gain the override until their owner logs in again. A
direct-SQL admin-key registration must stamprole_checked_attoo — the
API path already does; a barerole='admin'insert is correctly refused as
never-checked, anddocs/SSH.mdnow says so explicitly. -
Session revocation.
POST /sessions/revoke(admin;wardyn sessions revoke --sub … | --all) invalidates every current console session for one
principal or for everyone, effective immediately (migration0049).
Sessions are stateless cookies, so revocation is a per-principal cutoff
time the middleware checks on every request — fail-closed when the store
errors. It also revokes every unrevoked API token the target holds: a
wdn_bearer is that human's session in another form, so "revoke a human
now" covers both in one call. Auditedsession.revoke(with
tokens_revoked); a request presenting a revoked cookie surfaces as
auth.failedwithreason: revoked_session. -
wardyn support-bundlegathers version, healthz, setup status, a bounded
audit tail and the compose config — secret values redacted, including
commented-out lines — into a tar.gz for a support ticket. -
OIDC token exchange retries transient IdP errors (5xx/timeout, at most
three attempts with backoff) and distinguishes them from a configuration
error on the error page. -
linux/arm64 images.
wardynd,wardyn-proxyand the agent images build
forlinux/amd64,linux/arm64(pure-Go cross-compile; QEMU only for runtime
stages); every base-image pin is an index digest, enforced by
scripts/test-image-pins.sh. The release lane signs and attaches an SBOM
for the agent images too, the agent CLI is pinned to an exact version, and
CI runs a trivy scan (CRITICAL fails; accepted CVEs live in.trivyignore). -
Managed desktop envelope.
deploy/desktop/wardyn.env.example+
docs/DESKTOP.md: a local daemon per laptop under an MDM-managed policy
file, developer = operator. The ceiling is stated verbatim — the developer
is not the adversary in this tier — and the operator-unclamped inline-policy
path is named, not hidden. -
Member-owned workspaces (backend, migration
0048). A member may now
create, update, delete and scan their own workspaces (owned_by); a foreign
member's workspace answers the byte-identical 404 a missing one does. A
member'slocal_dirmounts are allowed only under operator/MDM-set roots
(WARDYN_MEMBER_WORKSPACE_ROOTS, or a per-member
WARDYN_MEMBER_WORKSPACE_ROOTS_MAPthat replaces the shared list),
canonicalized at bind time (symlink and..escapes refused,$HOME
dotfiles denied), and writable only underWARDYN_MEMBER_WRITABLE_ROOTS
minusWARDYN_MEMBER_WRITABLE_DENY— both unset means no writable member
mount at all.WARDYN_MEMBER_MODE=1refuses to start alongside local mode.
The console flow and the reassign action follow in the next stage. -
The
wait_for_reviewhold window and concurrency are configurable. A
policy may setfirst_use_hold_secondsandmax_holdsinstead of living
with the built-in 30s/16; absent or zero keeps today's defaults. Note the
cap is per held connection, not per distinct host — N concurrent connections
to one unknown host consume N slots. -
A denied CONNECT is distinguishable from one waiting on approval. The
proxy's 403 now carriesX-Wardyn-Egress: denied|approval-pendingplus
X-Wardyn-Host(the refused host), so an agent — or a person reading its
logs — can tell a hard deny from a first-use hold without grepping the
audit log. -
Audit list: per-principal
?actor=filter and an uncapped NDJSON
export.GET /audittakes?actor=alongside the existing filters, and
GET /audit/exportstreams the full filtered result as NDJSON — the "give
the auditor everything for this principal" request stops being a pagination
exercise. -
Per-sink SIEM delivery-drop counter on
/metrics. A webhook sink that
exhausts its retries now incrementswardyn_audit_sink_drops_total{sink=…}
instead of failing silently — the number a pilot's monitoring should alarm
on. -
A run that dies before its agent starts carries a
failure_hint.
Dispatch-side failures (unresolvable image, lost sandbox, inspection
refusal) used to land as a reason-less FAILED badge with the cause buried in
the audit log; the run row now carries the one-line reason (migration
0044; console rendering lands with the UI lane). -
Enterprise-POC documentation set:
docs/DATA-FLOW.md(vendor-
questionnaire-ready data-flow and sub-processor statement),
docs/AUDIT-ACTIONS.md(the audit action vocabulary, curated from every
emit site), an honest audit-retention/erasure section in OPERATIONS.md,
OFL-1.1 font attribution in NOTICE, and six newly-disclosed residuals in
the threat model. -
UI sandboxes: a governed relay from your browser to one declared port
inside a run's sandbox (docs/UI-SANDBOXES.md). A run's policy may declare
ui_apps— a name, a loopback port and a path, operator-authored, never a
command string — andwardyndrelays exactly those ports, over the same
exec lane (socatonRunner.ExecStream) the SSH gateway's-Lforward
already uses: no pod/container-IP dial, noNetworkPolicychange, no new
network path out of the sandbox. Off by default; it exists only when
WARDYN_UI_SANDBOX_LISTENnames a second address, and boot refuses one
equal to-listen— what the relay serves is the sandbox's own JavaScript,
and the separate browser origin is what keeps it away from the console's
session. Access is a single-use, 30s, owner-or-admin attach ticket (the same
one the browser terminal mints) redeemed for a path-scoped,HttpOnly
session cookie; the listener has no other credential and never falls through
to the console session or admin bearer. Forwarded requests are stripped of
everywardyn_*cookie plusAuthorizationand any?ticket, and responses
are stripped ofSet-Cookie: wardyn_*. Nothing inside a relayed app is
recorded — no keystrokes, no screen, no page content; the audit trail is
ui.auth/ui.start/ui.open/ui.close, deliberately distinct from
session.attachso a relay session never appears in the recording picker.
Deployment:uiSandbox.*in the Helm chart (its own port, and its own
hostname — the README says why), and a loopback-only compose mapping on
WARDYN_UI_SANDBOX_PORTthat stays inert until the gateway is enabled.
On the console, a run's "Attach from your terminal" card gains a third lane
beside the Wardyn CLI and SSH: off, no-apps-declared, or one row per declared
app with an Open button that mints an attach ticket and opens the relay's own
origin in a new tab (window.open(…, "noopener"), never an iframe — an
iframe is precisely the same-origin risk the second listener exists to
avoid). The policy detail sheet showsui_appsread-only; there is no
in-console editor in 0.6. Boot also refuses the second listener on a routable
address with no TLS posture —-ui-sandbox-listen 192.168.1.5:8081behind a
loopback-listenpreviously served the 8hwardyn_ui_sessrelay cookie in
cleartext on a LAN interface, exactly the class the console's own guard
already refused. The rule now lives beside the posture both listeners share
(refusePlaintextListen), so the loopback/unspecified carve-outs and the
WARDYN_ALLOW_PLAINTEXT_LISTENescape hatch cannot drift apart
(docs/ENV.md). -
wardyn/agent-vscodeimage variant (make agent-image-vscode,
deploy/images/vscode/): the claude-code image plus a pinned,
sha256-verifiedcode-serverbound to127.0.0.1:8080and a
/usr/local/bin/wardyn-ui-vscodelauncher. The launcher path is the whole
BYOI contract — any image can serve a declared app by shipping one, and an
image without it gets a clean 502 naming the missing path, never a hang.
~+228 MiB over the base image, and not part ofagent-images. -
Permissioning: capability grants for a user, a group, or everyone. An
admin can now grant — or deny — one member, one IdP group, or every signed-in
human a specific Wardyn capability, on four kinds:egress_host(which hosts
they may decide anegress_domainapproval for, and which may survive on
their owninline_policyallowlist),secret(which stored secrets that
policy may reference, and which namesGET /secretslists back),workspace
(which onboarded workspace they may launch against), andimage(which custom
sandbox image they may name at all — the one kind that widens what a member
can do;devcontainer_repostays unconditionally admin-only). Rows live in
capability_grantswith a per-kind enforcement switch in
capability_enforcement(migration 0042), managed throughGET /permissions,
POST /permissions/grants,DELETE /permissions/grants/{id}andPUT /permissions/enforcement(all admin-only), withGET /me/capabilitiesas the
member-safe read of the caller's own effective set. Resolution is deny beats
allow beats the switch, with admins, the admin token, and local mode exempt,
and no cache (a new grant applies on the next request). Every switch ships
off: a deployment upgraded from 0.5 with no rows written behaves
byte-for-byte as it did before. The doctrine — a capability bounds what the
MEMBER chose, never what the ADMIN pre-authorized — is why a stored policy, a
workspace's requirements, scan-seeded hosts, and the model provider's own
egress are never narrowed. See docs/OPERATIONS.md →
"Capabilities: what one member, or one group, may do". -
An OIDC session now carries the group snapshot its capability grants match
(internal/auth/oidc): the union of the ID token'srolesandgroups
claims, lowercased/deduped/sorted/printable-ASCII, capped at 2048 payload
bytes and dropped from the alphabetical end so the truncation is deterministic
and the signed cookie stays under the ~4096 bytes a browser silently discards
whole. Membership is a login-time snapshot; grants themselves resolve per
request. No forced re-login: a pre-0.6 cookie has no groups field, stays
valid, and is reported distinctly asgroups_snapshot_stalerather than as
"holds no groups". -
Ground-truth heartbeat and
/healthznow publishdropped_unmapped
alongside the existingdropped_totalandobserved_total, and the
/healthzidle state names which of its two causes it is ("kernel events
observed but none correlated to a run" vs. the plain "no kernel events
observed"), so "the sensor saw nothing" and "the sensor saw plenty and
correlated none" stop reading as the sameobserved_total: 0. -
SSH gateway admin override. A registered public key now carries the
role it was registered under (rolecolumn, migration
0043_ssh_key_role.sql), andsshAuthauthorizes a connection if
run.created_by == the key's principalORkey.role == admin— an
admin's own key now reaches any run over SSH, not just the browser
terminal. The override is stamped at registration time, not checked live:
it is honestly weaker than the web terminal'srequireOperatorgate,
which re-reads the session's role on every attach, so a demoted admin's
already-registered key keeps the override until that key is deleted and
re-registered (or revoked) — there is no expiry or background sweep. Every
override connection is audited distinctly (ssh.authsuccess carries
override:truewhenever the owner check did not match), and a member's
key never satisfies the check regardless of registration age. Upgrading:
a key registered before 0.6 is backfilled asmemberand never gains the
override — the stamp is written only at registration and nothing
re-stamps it, so an admin who registered a key under 0.5 must
DELETE /me/ssh-keys/{fingerprint}and register it again to receive one.
See docs/SSH.md → "Bounds" andthreatmodel/THREAT-MODEL.md
residual #15. -
One command from a bare host to a real Kubernetes cluster.
make kind-quickstart(deploy/kind/quickstart.sh)
buildswardyndlocally, stands up akindcluster with a version-pinned
Calico CNI and the k8s runner substrate on,helm installs the chart, and
waits for a healthy control plane — the exact path CI'shelm-install-test
andconformance-k8sjobs already prove, now runnable by an operator in one
command, printing the URL and admin token it minted. Both host port mappings
bind127.0.0.1explicitly rather than0.0.0.0, so a leftover compose stack
on the same ports fails loudly at cluster-create instead of silently
absorbing the quickstart's traffic; the healthz proof names who answered,
because both stacks publish127.0.0.1:8080and a 200 says nothing about
which one replied.make kind-downtears it back down. The chart README now
leads with this path before the full production install walkthrough, and
docs/README.md links the Helm deployment lane at all.
Day-2 operations on Kubernetes are documented from commands run against a
live cluster (docs/OPERATIONS.md). -
GET /readyz— a real readiness probe./healthzreports "ok"
unconditionally with no Postgres check, but the chart used it for readiness,
so a dead database read healthy and never left the Service's endpoint list.
/readyzpings the store with a 3s timeout (503 on failure) and is what the
chart'sreadinessProbenow targets; liveness and startup stay on/healthz
so a transient DB blip does not restart-loop an otherwise-fine pod. The probe
path is a chart value (readinessProbe.path), pinnable back to/healthz
for images at or below 0.5.0, which predate/readyzand would otherwise
stall every rollout at "not ready". -
/metricscan see a dead store and a backed-up audit spool. Every
existing counter only moves on success, so a Postgres outage looked identical
to an idle control plane on the scrape surface. Two gauges close it:
wardyn_store_up(the same bounded ping/readyzmakes) and
wardyn_audit_spool_lines— a failed durable audit write spools to local
JSONL for a background drain loop to replay, and a spool that never returns
to 0 means that loop is not working, a condition that previously had no
operator-visible signal at all. -
wardyn ssh <run-id>— no more copy-pasting the connect string. It
reaches a run over the SSH gateway directly, exec'ing the localssh(1)
binary against the address read off the gateway's own/healthz— the same
one the console's SSH card surfaces. It is a deliberately separate command
fromattach, not a flag on it:attachcarries the admin bearer over a
WebSocket,sshcarries a registered public key over the real SSH protocol,
and collapsing the two would silently swap which credential a run session
used.--printemits the raw command and--configanssh_configHost
block, both identical to what the run-detail card renders for the same run;
the card now names the shortcut inline, above the raw command it replaces.
A by-hand lane exercises the gateway against a Pod on the k8s substrate as
well as against Docker (make test-e2e-ssh-k8s) — a manual proof, not a CI
job: it runs against a clustermake kind-quickstartleaves behind, so a
green result is evidence only for the tip someone actually ran it on. See
docs/SSH.md. -
wardyn logs <run-id> [-f]tails a run's audited event trail (dispatch,
egress, credential mints, completion) by reusing the existing audit-events
pipeline. There is no raw agent stdout/stderr capture for exec-mode runs, so
the command is honestly scoped to what actually gets audited. It reports an
unknown or unauthorized run id immediately — with or without--follow—
rather than exiting 0 on nothing or polling forever, and a followed run's
tail runs until the run's terminal audit rows are drained, not merely until
the run's state flips. -
approvals list/getgain run and host visibility.approvals list --run <id>filters by run — the SDK'sListApprovalsnow actually sends
?run_id=, dead since decision scopes shipped it server-side — aHOST
column is parsed fromrequested_scope, and aHOLDcolumn flags a live
wait_for_reviewegress hold with its remaining window.approvals get <id> --run <run-id>fills the gap left by there being noGET /approvals/{id}. -
The default ceiling policy is viewable — UI, CLI and API.
GET /policies/defaultexposes the ceiling every policy-less run gets (the same
one a member's inline policy is clamped against), previously unexposed on any
surface. Reachable aswardyn policy default, the SDK'sGetDefaultPolicy,
and an expandable card on the Policies screen. -
A Permissions screen, and inline why-denied moments for members. Admins
get a seventh sidebar entry showing the doctrine, each of the four capability
kinds with a live sentence naming what it currently does or does not enforce,
the grant table and an add form — a grant renders amber with no success
toast, and an unenforced kind is labelled "Advisory until enforced"
throughout, so the screen never implies a bound it is not applying. Members
get three inline deltas driven byGET /me/capabilities: anegress_domain
approval whose host they were not granted disables both decisions with the
reason beside them; New Run annotates — never hides — the workspaces a
member cannot launch against, because hiding would make the refusal
undiscoverable; and the Secrets list says outright that it is showing only
the names that member holds. All of it is advisory: the server remains the
enforcement point. -
A live safety meter while you author a policy.
POST /policies/grade
runs the samecomposer.Gradeverdictpreflightcomputes for a launch,
against a bare, unsaved spec — member-accessible, strictly decoded like
every other policy write. The policy panel calls it debounced and paints a
4-segment meter (Safest · Guarded · Elevated · Weakest); a parse failure
dims it, and the title makes clear it grades the document, not the
resolved run Preflight grades. -
A Preflight button on the New-Run screen. A secondary button beside
Launch now sends the exact payload Launch would (one shared
buildRunInputprojection, not a hand-copied one) and renders the
server's verdict inline — field-path 400s verbatim, or the risk grade,
member-clamp warnings, and enforced confinement class via the same
RiskBadge/ConfinementChipthe run page uses. -
A confined replay now carries an explicit Clean/Caught verdict.
CleanReplaystamps eachCONFINEDrecord-loop replay clean or caught —
false on truncation, any deny, any pending, or an allow released only by
a live mid-replay approval. The confined chip renders "Replayed clean",
"Replayed — caught N" (warning tone), or "Replayed — not clean" with its
cause named; the guided action approves just the hosts you select and
replays again in one click, and a workspace's session list gains a
roll-up line for whether its loop has ever closed clean.
Changed
POST /runsandPOST /runs/preflightnow decode their request bodies
strictly, matchingPOST /policies: an unknown field (including a typo'd
one nested insideinline_policy) is now a 400 naming the field, where it
was previously ignored silently. Compat note: this can break an external
SDK/CLI client sending a field newer than an older server understands —
previously tolerated version-skew now hard-fails instead of degrading.imagemoves from admin-only to grantable, andworkspacebecomes
gateable (denyMemberRequest, formerlydenyMemberCustomImage): a member
naming a custom image now needs theimagekind enforced and an exact-ref
grant (unenforced still refuses, exactly as 0.5 did), while naming a workspace
stays allowed until an admin enforcesworkspace. A member'sinline_policy
is narrowed, after the existing operator clamp, to the hosts and secrets that
member personally holds — dropped with a warning, never rejected, so the run
still launches on its admin-authored egress. The warning appears twice: on
the preflight/Review dry-run before launch, and again on the201of the
launch itself, where the console raises it as a toast andwardyn run
prints it to stderr.GET /secretslikewise lists a member only the names
their own grants cover, oncesecretis enforced.- BREAKING —
pkg/client.ListApprovalsgains arunID uuid.UUID
parameter, positionally betweenstateand the variadicListOpts:
ListApprovals(ctx, state, opts...)becomes
ListApprovals(ctx, state, runID, opts...). Passuuid.Nilfor "every
run" — the previous behaviour. The method never sent the?run_id=filter
the server has supported since decision scopes shipped; adding it as an
option would have left the filter as easy to forget as it already was.
Every SDK caller must update to compile. - Upgrading a Kubernetes install:
helm upgrade --reuse-valuesis still not
the path across 0.5 → 0.6.--reuse-valuesreplaces the new chart's
values.yamlwith the previous release's, so the value blocks 0.6 added
(the UI-sandbox gateway, the readiness-probe path) are absent from the map
the templates read. The chart now reads every one of them through a
default dictand itsvalues.yamlleaf default, so that upgrade renders
instead of dying on a nil map — but it renders with the new defaults and no
way to see them. Use-f your-values.yaml, or--reset-then-reuse-values
(Helm ≥ 3.14), which starts from the new chart's defaults and layers the
previous release's overrides on top. docs/OPERATIONS.md
→ "helm upgrade, and why--waitis not optional" carries the recipe and
the two Helm sharp edges it steps around. /runs/newand/policiesnow author policy through one shared
panel. The wizard's bespoke Confinement/Network cards, presets,
Unlisted-host dialog and Record radio are gone, along with/policies'
separate editor; both screens render the same spec textarea, template
chips (Minimal, Model provider, Package registries, CI baseline,
Allow-all) and helper rail. On/runs/new, editing the spec detaches a
chosen saved policy, and the Barrier selector up-clamps to the active
floor with a reason line on every disabled tier.- The demos catalog moves into Getting Started;
/demosnow redirects
there. The Getting Started demo phase lists the whole catalog — split
into Egress demos and Secrets demos sections — replacing the old frozen
five-step subset;/demosredirects to/setup?step=sealed-box, the
same pattern/integrationsalready followed.DemoDetailis the one
renderer for both sections now.
Fixed
-
The default agent image pulls the daemon's own version tag, not a
floating:latest. A daemon at vX.Y no longer silently picks up whatever
image was pushed last; the fallback resolves to the matching version tag. -
A sandbox orphaned by a crash before its ref was recorded is swept. The
boot reconciler only knew sandboxes by their stored ref; a crash in the
window beforeSetSandboxRefleft a live, credentialed container nothing
would ever revisit. The reconciler now also sweeps by the run-ID label the
runner stamps on every sandbox it creates. -
Agent-CLI telemetry is suppressed inside the sandbox, so a pilot's
first-use egress approval prompt is for the code host — not for the
harness's own metrics endpoint. -
The compose stack survives a host reboot —
postgresandwardynd
carryrestart: unless-stopped. -
The UI license gate fails closed.
scripts/check-ui-licenses.shwas a
denylist (an unknown new license passed); it is now an allowlist. -
Secrets: the Value field masks at entry, with a reveal toggle. A
write-only store no longer puts the plaintext on screen while it is typed
(-webkit-text-security, so multiline PEM values keep working; Firefox
ignores it and degrades to plaintext — cosmetic masking, not a security
boundary). The reveal state resets each time the dialog opens, so the next
Add/Rotate never inherits the previous one's plaintext. -
An exec-mode run's page stops calling it an agent. A run launched with
no agent and no model was chipped "autonomous — the agent drives", badged
"agent exit 0", and watched "anything the agent tries".task_modeis
request-scoped and lives only in therun.createaudit event, so the run
page derives it from the trail it already holds: an exec run now chips
"exec — shell command, no agent harness", the exit chip drops the word (it
is true in every mode), and the idle hint says "anything this run tries". -
The recording banner derives its tier from the runner, not from a
New-Run preference. The Recorded-sessions warning card guessed the
confinement tier from the operator's persisted New-Run default in
localStorage— an unrelated setting — falling back to a hardcoded CC1, so
a capture that actually ran under Vault was captioned "Fence — the weakest
barrier". The backend launches every recording under the runner's
strongest class, and the card now reads that: the open-recording-allows-
all-egress line is stated on every tier, and the weakest-barrier line is
added only when the session genuinely runs under CC1. -
Ground-truth's control-plane counter could freeze on a live sensor. The
ingest sidecar built its container→run index from adocker pssnapshot
(running containers only) and replaced it wholesale on every refresh, while
the Tetragon export tails with lag — a run whose container exited before the
tail caught up resolved unmapped, was dropped, and moved no counter at all.
The index is now fed bydocker events(a container is known at CREATE,
before its first exec) and merged rather than replaced, with entries
outliving their container by 15 minutes so a lagging tail still correlates. -
A persistent-Postgres install with the default ephemeral age key now
refuses to render, instead of crash-looping on its second restart.
External-DSN installs pair with the default ephemeral age identity —
regenerated every boot — so a second restart cannot decrypt what the first
boot encrypted. That combination previously installed cleanly and only failed
later, unrecoverably. The chart refuses it outright
(secrets.allowEphemeralAgeKey=trueis the explicit "I accept losing every
stored secret on restart" opt-out, the same shape asallowMultiReplica),
and the README's own external-DSN install commands — which walked operators
into the same trap — now passsecrets.ageKeyFromSecret=truethroughout. -
The egress canary names the ambient-NetworkPolicy trap — and stops
recommending a fix that would have widened every sandbox's egress. A
pre-existing default-denyNetworkPolicyin the runs namespace, unrelated to
Wardyn, blocks the canary's baseline-reachability phase and produced an
INDETERMINATEboot refusal with no documented cause. The error and the
chart docs now name it directly, and the originally-suggested remediation (an
allow-rule forwardyn.managed=true) is corrected: that rule is additive and
both the agent and proxy pods carry the label, so it would have widened every
run's egress past its per-run deny+proxy-only policy and flipped the canary
to "CNI does not enforce". The documented fix is to exempt Wardyn's pods from
the ambient policy's ownpodSelector, or to use a clean namespace. -
make resetwarns before destroying the corporate baseline it shares with
make reset-all. Plainresettook the upstream proxy, artifact mirrors
and SCM host configuration down with no warning and no capture command. Both
paths now print one shared hint — and only whilewardyndis actually
running, because the capture command runs inside it, so the hint is withheld
exactly when it could not work. -
make doctoris read-only again, and honoursWARDYN_PG_PORT. Its
socket-mountability probe silently pulledalpine:3.20from the network; it
now runs--pull=neverand skips outright when the image is not already
local. Its Postgres port check respects the sameWARDYN_PG_PORToverride
its api/registry/ssh siblings already did. -
A typo'd
site-config applykey fails on the host instead of deleting the
setting.applyreplaces the whole stored document, so a misspelled field
was silently dropped by a lenient decode and the real setting erased with it.
The CLI now decodes strictly and reports how manyIntegrationsentries a
round-trip silently dropped, rather than exiting 0 as though nothing were
lost. -
wardyn setup wall|vaultstops exiting 0 when nothing was enabled. An
unsupported host, a plan-only run, a declined confirm, or a non-interactive
empty stdin all silently "succeeded" at doing nothing. Every such path now
exits 1 naming the reason, and a successful--runre-probesdocker info
for the runtime actually in effect rather than trusting the install script's
exit code alone. -
A paste over 32 KiB no longer kills the web attach terminal. The attach
WebSocket had no explicit read limit, socoder/websocket's default 32 KiB
cutoff closed the whole session — rather than truncating — the moment an
operator pasted a long patch or log. The limit is now explicit at 1 MiB. -
A long attach session's recording is truncated, not thrown away. The live
asciicast was buffered unbounded in the daemon's heap and written once at
close, so a long session hit the recording store's 64 MiB cap and was
rejected whole — the entire session's evidence lost at the moment it should
have been persisted. The buffer is capped at 8 MiB and an over-long session
is saved truncated (audited withtruncated:true) rather than not saved at
all. A redundant per-keystrokeagent_runsUPDATE went with it: a 30s
keepalive already covers liveness. -
ci-run.shsurvives a failed refetch and a job cancel. A failed post-run
refetch used to truncate the already-capturedrun.jsonvia shell
redirection before the fetch ran; it now refetches to a temp file and
replaces the real one only on success. A hard cancel (SIGTERM) skipped
teardown entirely because cleanup ran only on theEXITtrap — TERM/INT now
route through it too — and the run's own terminal recording is collected into
the CI output directory instead of being dropped with the recordings volume. -
--dry-runpreflight stops flagging a false "missing model access" blocker
for exec runs. The preflight checklist called the same model-access
resolver as the real launch path unconditionally, but launch itself exempts
task_mode=exec— so every CI exec job's dry-run preview showed a blocker
launch would never raise. -
A duplicate policy name is a 409, not a raw 500. Creating a policy with a
name already in use returned a blanket server error carrying the raw database
message; it now maps to a 409 whose message the create-policy dialog surfaces
directly. -
A member can start a demo again. Redacting setup status for members
zeroed the confinement-classes field along with genuine diagnostic detail —
but the demo screen's Start-button readiness check reads that same field, so
no member could start a keyless demo regardless of the runner's real state.
The field survives redaction now; the driver name and per-class substrate
detail still do not. -
A credential approval that simply expired no longer wedges the run
permanently.ensureApprovalkept re-finding the same aged-outEXPIRED
approval on every retry, and minting mapsEXPIREDto a denial — so a run
blocked on an approval nobody reached in time was stuck forever. Both lookups
skip expired rows, so the next attempt raises a fresh pending request; a
genuine human denial still stays terminal. -
A malformed verify-egress approval audits its own no-op. Approving a
request with an empty or malformed host inrequested_scopesilently wrote
nothing to the workspace's requirements contract — a green UI with no durable
effect. The guard now emits an audit event on the miss, matching the
merge-failure path beside it. -
A member's empty Audit feed, the link into it, and its truncation now tell
the truth. A member's unfiltered/auditis always empty by design — the
server scopes non-admins to?run_id=of a run they own — but the copy read
this as "you have no runs yet". The copy is fixed, the run-detail Audit tab's
link into the full feed carries?run_id=so the filter is actually
reachable (and survives a reload via URL state), and the tab notes when it is
capped at the server's 1000-row default. -
The console stops polling the expensive setup-status endpoint twice, and
warns before an SSO session dies mid-work. The top bar's barrier chip ran
its own poll of/setup/statuson top of the app shell's separate poll of
the same endpoint — which runs a full run list plus a host sweep that shells
out. The two collapsed into one, then the heartbeat need itself moved to the
cheap, unauthenticated/healthz. A warning now appears before an SSO
session's ID token expires, instead of a silent 401 wiping the console back
to sign-in. -
Console honesty fixes. The setup footer's gate action button is
operator-gated, matching its sibling inline Test buttons. Container-login
success text names the provider actually connected instead of always claiming
a Claude subscription. The welcome screen stops reading an unreachable daemon
as a real "needs setup" state and shows the same "Checking…" state the rest
of the app uses. The operator-only refusal text names admin, not a role
that does not exist. The confined-review card honours approvals recorded on
the requirements contract, not just the legacyapproved_egresslist, so a
host approved live during a confined replay stops rendering blocked behind a
duplicate-writing Approve button. The live-hold badge stops treating any
pendingwait_for_reviewapproval as an active hold — the proxy's real hold
times out in 30s while the approval can stay pending for up to 24h — and a
failed poll stops rendering as "all clear". -
Sandboxes get lowercase proxy env too.
curland most HTTP clients
(post-httpoxy) deliberately ignore the uppercaseHTTP_PROXYfor plain-
http://URLs, so an in-sandboxhttp://fetch bypassed the proxy
outright and failed DNS instead of being inspected.http_proxy/
https_proxynow joinHTTP_PROXY/HTTPS_PROXYin every run's
environment. -
The compose stack's RBAC env now actually reaches the container.
WARDYN_OIDC_ROLE_MAPandWARDYN_OIDC_DEFAULT_ROLEwere never
plumbed intowardynd's environment indocker-compose.yaml— the
admin/member RBAC path the README describes was silently inert on
compose, the only deployment with the gap (desktop env and the Helm
chart both already carried them). Both variables now reach the
container. -
AWS_CLI_INSTALL=stagednow actually reaches the aws-sso image
build. The Makefile never forwarded the build-arg to
DOCKER_BUILD_ARGS, so an offline/strict-allowlist
make agent-images AWS_CLI_INSTALL=stagedsilently fell back to
downloading the AWS CLI installer over the network instead of using
the staged one. The arg now joins the other install-mode args on all
four image builds.
Security
-
A multi-dot host spelling could slip past an explicit egress deny. The
proxy's host normalizer stripped exactly one trailing dot,
so underallow_all_egressa CONNECT toevil.com..:443failed to match an
explicit deny key forevil.comand sailed through. All trailing dots are
now stripped before any policy comparison, and a regression test pins every
multi-dot spelling to the same decision as the bare name. -
credential.mintis written inside the mint transaction. The audit row
for a brokered credential mint committed separately from the mint itself, so
a crash between the two could leave a minted credential with no audit trace
(or the reverse). The row now commits atomically with the mint; the same
pass closed the sibling seams — a decidedalwaysegress approval whose
workspace write-back was lost to a crash is now healed by a boot-time
reconcile (the API layer cannot share a transaction across the decision and
the workspace write, so the window closes at the next daemon boot rather
than shrinking to zero), and the audit spool's crash-recovery keeps the
good head of a torn tail instead of discarding it. -
Secrets masked in audit
Dataeven when JSON-escaped. The audit masker
compared raw secret bytes, so a value containing quotes/backslashes appeared
unmasked in event payloads once JSON-encoded. The masker now also matches
the JSON-escaped form of every registered secret. -
wardyn-ssh-host-keywas listable and overwritable through the generic
secrets API. The SSH gateway's ed25519 host key sat in the broker's
reserved set but not ininternal/api's, so the half of the guard facing
the operator never applied:GET /secretslisted it,PUT/DELETE
overwrote or removed it — a junk value regenerates the host key at the next
boot and breaks every pinned fingerprint, which is the warningssh(1)
prints for a man-in-the-middle — and anapi_keygrant could name it. It
is now reserved on both sides, the same omissionwardyn-ui-session-key
had. The two hand-written maps live in packages that cannot see
cmd/wardynd's platform-key constants, so the test that would have caught
this lives incmd/wardynd, where all three are visible, and fails if any
daemon-GENERATED key is missing from either set. The operator-PROVIDED
GitHub App pair stays deliberately out of it: those must remainPut-able. -
A member's dropped secret pairing is now audited, not just warned about.
filterMemberGrantsdrops aninline_policygrant that pairs a stored secret
with a host the operator never eligible-listed; that drop previously produced
a clamp warning and no audit event, so a deliberate exfil attempt left no
operator-visible trace (a gap ROADMAP.md named). It now records
anauthz.deniedevent with reasongrant_pairing_not_eligible, aggregated
one event per reason with the affected values beside it — never on a preflight
dry-run, where a stream of denials for a policy nobody launched would be
indistinguishable from denials that actually bounded a run. Capability drops
audit the same way (capability_egress_host,capability_secret), and a
capability refusal at launch or at an approval decision audits as
capability_workspace,capability_egress_host, orbyoi_member. -
k8s.enabledrefusesserviceAccount.create=falsewith no explicit
name. That combination let the k8s-runner RBAC role —pods/exec,
secretscreate/delete,networkpoliciescreate/delete — silently bind to
the namespace'sdefaultServiceAccount, and therefore to every other pod in
the namespace using it. The chart fails the render for that exact
combination; an explicitserviceAccount.namestill renders fine. -
The
?ticket=attach lane audits its own refusals. This route is the
only path to a live terminal that bypasseshumanOrAdminAuth, and it
recorded no denials at all — a scan against it with guessed tickets left no
trace, unlike the SSH gateway'sssh.auth. Both refusal shapes now emit
session.attach/failurewith the source IP. -
Plaintext run credentials no longer live for the daemon's lifetime.
wardyndheld every run's plaintext secrets in memory indefinitely, so a
long-uptime daemon accumulated the credentials of every run it had ever
dispatched with no eviction path in production. A background sweep evicts a
run's secret corpus one hour after it goes terminal, on a 15-minute ticker,
and fails closed: a store error or an unresolvable run id keeps the secrets
rather than guessing them safe to drop. -
A member could read a colleague's run telemetry on a shared workspace.
GET /workspaces/{id}/observed-egressaggregated denied-egress targets from
every run that had touched the workspace with no per-caller filter, leaking
telemetry from the very runs/runs/{id}itself 404s that member out of. The
endpoint applies the same owner-or-admin scoping as the runs list. -
The react-router pnpm-audit suppression is gone — the advisory it covered
was already patched.GHSA-qwww-vcr4-c8h2patches at both 7.18.2 and
8.3.0, not only at the 8.x major the suppression's comment claimed; a stale
"no patch exists on 7.x" note keptignoreGhsasalive well past the release
that refuted it.react-router-dommoves^7.18.1→^7.18.2and the
suppression is deleted outright —make npm-auditpasses against the real
advisory set with nothing ignored. The 7 → 8 major stays a named gap in
ROADMAP.md, now on its actual merits: every stable 8.x
peer-depends on React >=19.2.7 against this console's 18.3.1, and
react-router-domhas no 8.x release at all — a React 19 decision for a UI
owner, not an advisory deadline.