v0.5.0
Pre-releaseSecurity
- Go-live hardening: 29 confirmed ship-blockers closed across two adversarial
review waves, each with a regression test proven to fail on the pre-fix
commit. The load-bearing ones: a member'sinline_policyllm_inspection
block is now clamped under the default ceiling (itsdetector_sidecar_url
could otherwise become an un-allowlisted egress channel), and its secret
corpus is referenced by name and resolved only at dispatch — never stored in
a policy row, written to the append-only audit log, or copied into a
compose/profile proposal; the git-broker's value-returning mint lanes refuse
the GitHub App private key and the other reserved platform secrets; a
disabled integration no longer grants a run model access; an explicit
WARDYN_LOCAL_MODE=trueno longer silently disables a configured OIDC/RBAC
deployment (boot refuses the contradiction); a client-settablerun.Taskcan
no longer forge the operator-reserved harness-login path; SSHssh.auth
success is audited only after signature verification, not at key-offer time;
an empty-ceilinggithub_tokenrepo list is deny-all for a hand-authored
spec; a tokened corp-mirror redirect is dialed to its real port, not always
443; the exec-less (krun/CC3) sandbox path now applies the same fail-closed
resource-cap gate as the exec path; and the host ground-truth sensor no
longer forwards uncorrelated host-wide kernel events to the audit log/SIEM by
default. - OIDC sessions now carry a derived admin/member role (
WARDYN_OIDC_ROLE_MAP,
internal/auth/oidc'sderiveRole). Upgrading forces one SSO re-login: a pre-0.5
session cookie carries no role and now decodes as no session (decodeSession), never
as an authenticated session with an undefined role. - Authorization enforcement: the admin/member role is now enforced, plus
owner-or-admin scoping.requireOperator/isOperatorgate on the session's
role instead of re-checkingWARDYN_OIDC_OPERATOR_EMAILSdirectly (the
allowlist still works — it feeds role derivation viaLegacyAdminEmails, one
source of truth instead of two).GET /metricsnow carries the admin gate
explicitly. A member is scoped to their OWN runs/approvals onGET /runs,
GET /approvals(unscoped), andGET /audit(?run_id=of an owned run,
else an empty result — never a cross-user leak);GET/kill/profile/grantson
a run, the recording replay, an approval decide, and the attach-ticket mint
all use an owner-or-admin gate that answers a foreign resource with the
byte-identical 404 a missing one gets (no existence oracle). Attach tickets
now carry the minting principal's role (migration 0034), since the
interactive-attach WebSocket's?ticket=lane authenticates entirely off the
ticket and never runs the normal session check.GET /setup/statusredacts
operator-diagnostic detail (environment checks, resident CLI detection,
secret names, runner detail) for a member. A member'sinline_policyon
POST /runs(and its preflight dry-run) is now clamped to the operator's
default policy ceiling before resolution, and bringing a custom sandbox
image (image) is admin-only. Two routes move from admin-only to
owner-or-admin: minting an attach ticket and deciding an approval, both
restricted to the run's own creator (or an admin) either way. A new
authz.deniedaudit action records a member's admin-surface or BYOI
denials (not a foreign-resource 404 — that stays silent by design, matching
the no-existence-oracle rule above).
Added
-
An interactive run can start on a seed, at boot. The run's
task—
previously ignored for an interactive run — is now its optional boot seed,
interpreted perinteractive_start: with"agent"the sandbox starts the
agent CLI on that prompt in a persistent tmux session the moment it boots
(supervised: the agent reads and plans, then parks its first tool approval
in the pane until you attach — setseed_auto_toolsto let it use tools
unsupervised before you join); with"shell"the seed runs as a startup
command before the terminal is yours. Attaching joins the live session.
Empty task = today's idle sandbox, unchanged. Server-launched runs
(record/verify/login) are excluded from seeding by construction. The seed
travels as env into the sandbox and is consumed at boot — needs an image
rebuild (make agent-images-core); an older image ignores it and comes
up idle. The CLI gains this with zero new flags:wardyn run --interactive
with a task now seeds. -
Autonomous Claude runs can park every tool action on a human:
tool_approvals: "hold". Instead of--dangerously-skip-permissions,
the run's claude executes under--permission-mode manualwith an
in-sandbox relay (wardyn-toolgate, a stdio MCP permission-prompt tool)
that raises each gated tool use as atool_callapproval — the exact
command or edit as the decision context — and blocks until an operator
approves or denies it in the console (the run cockpit's approval strip now
shows tool holds beside egress holds). Deny and expiry both refuse the
action and the run continues; a relay that cannot reach the control plane
denies rather than proceeds. Default stays"auto"(the sandbox is the
boundary);holdis per-run, Claude-only (codex has no external approval
contract), and read-only commands the harness itself deems safe still run
without asking. -
Egress approvals carry a decision scope:
once,run,until, or
always.POST /approvals/{id}/approveand/denyaccept
decision_scope(plusdecision_expires_atforuntil) on an
egress_domainapproval;wardyn approve/wardyn denygain--scope/
--until, the SDK gainsDecisionOpts(pkg/client), and the console's
approval queue gains a scope picker (Once / This run / Until… / Always)
beside Approve/Deny. Omit the field and nothing changes — the default
staysrun, today's original behavior, held in the proxy's per-host cache
for the rest of the run.oncereleases a single connection (one CONNECT
tunnel on HTTPS; one request on plain HTTP) and is spent on first use;
untilis the same, bounded by adecision_expires_atup to 30 days out
and enforced by the run's own proxy sidecar;alwaysis
operator-only and persists the host onto the target workspace's
approved_egress/denied_egress(migrations
0039_approval_decision_scope.sql,0040_workspace_denied_egress.sql,
0041_run_workspace_ids.sql) so every future run against that workspace
inherits the decision instead of re-raising it — deny beats allow, as
everywhere else in the proxy. NewPUT /workspaces/{id}/denied-egress
(full-replace, mirrorsapproved-egress) is the only way to undo a
permanent deny, including one that broke a workspace's own credential
injection. -
Runs have a name.
POST /api/v1/runsacceptstitleanddescription,
both persisted on the run (migration0038_run_title.sql) and returned by
every read. Runs that share a title are grouped on the Runs board — which
now groups by title rather than by state; the triage the state sections
provided survives as the state facet, attention-first group ordering, and
per-state counts in each group header.wardyn rungains--title/
--description. Both fields are optional on the wire and required in the
console: the site-config probe, harness login and workspace record/verify all
create runs with no human to name them, so a server-side requirement would
break them. Untitled runs — including every run created before this — display
by their task exactly as before. -
An interactive run can open straight into the agent.
interactive_start:"agent"makes the attach shell launch the image's agent CLI
in the prepared workspace, once, on first attach;"shell"(the default) keeps
today's bare terminal. Request-scoped liketask_mode— carried to the sandbox
asWARDYN_INTERACTIVE_STARTand consumed by the image's attach~/.bashrc,
so it covers the console terminal and the SSH gateway alike (both go through
the sameRunner.Attach). Needs an image rebuild —make agent-images-core
— to take effect; until then an older image ignores the variable and degrades
to a shell, which is the previous behavior. -
The Integrations page's Tools tab, the integration→tool "carries"
chips in the workspace wizard (base-image, build, and verify steps), and
the client-side mirrors of the bake conditions. Tools are what the image
carries; the Integrations surface now speaks only to connections. -
The
artifact_mirror/host_proxyderivations. The Integrations surface
no longer synthesizes rows fromEgressRedirects/UpstreamProxySecretRef:
that is network topology, it already has a surface (Corporate network), and
showing it twice made one config look like two. Nothing about how a run
redirects or chains through the corporate proxy changes. -
Kubernetes runner substrate (
internal/runner/k8s,-tags k8s,
WARDYN_RUNNER=k8s): a second, independent confinement substrate behind the
existingsubstrate.Substrateseam — wardynd creates/manages sandboxes as
pods instead of Docker containers. L1 (NetworkPolicy-enforced), not L0
(structural) like Docker: a boot-time two-phase egress canary proves the
cluster's CNI actually enforcesNetworkPolicybefore the substrate will
start at all, refusing to boot otherwise
(WARDYN_K8S_ALLOW_UNENFORCED_NETPOL=1is the loud, logged opt-out). CC1
out of the box;WARDYN_CONFINEMENT_MAP/the chart'sk8s.runtimeClasses
pin CC2/CC3 to a registered RuntimeClass. Not at parity with Docker yet —
no BYOI/devcontainer builds, nolocal_dirmounts, no per-pod PIDs/disk
enforcement, no k8s ground-truth correlator (see
deploy/helm/wardyn/README.md/docs/OPERATIONS.md's "Known gaps"). -
The Helm chart (
deploy/helm/wardyn) can now create sandboxes, not just
the control plane.k8s.enabled=truewires the substrate above into a
real install: least-privilegeRole/RoleBinding+ClusterRolescoped to
exactly the verbs the substrate issues, a default-denyNetworkPolicy
extended with apiserver egress and a runs-namespace ingress peer, and a new
k8s_egress_containmentsetup check the console surfaces (Enforcing / Not
enforcing / Indeterminate).test/conformance'sconformance-k8sCI job
now proves the substrate on a real cluster (kind,disableDefaultCNI+ a
pinned Calico manifest — kind's default CNI does not enforce
NetworkPolicy); the suite's one L0-specific case self-skips there by
design (the substrate claims L1, not L0) and a dedicated L1 case proves
what it actually claims instead. New.claude/skills/wardyn-k8s-setup
skill: cluster prereqs, values authoring, wiring Entra ID App Roles for
admin/member RBAC, install/verify, and a symptom→cause→fix table. -
Native SSH into a running sandbox.
wardyndservesssh <run-id>@host(registered public keys only, owner-only authorization)
directly into the same tmux session the web terminal attaches to: exec
(exit-code propagation), the sandbox's ownsftp-serversubsystem, and
-Lport forwarding restricted to the sandbox's own loopback. Each
primitive gets its own audit action (ssh.exec/ssh.sftp/ssh.forward);
the shell path is recorded exactly like the browser terminal (ssh-
prefixed session key). Off by default (WARDYN_SSH_LISTENunset — no
listener, no host key even generated). Seedocs/SSH.md. -
Member console. The web console is now role- and kind-aware: a
member's nav hides operator-only surfaces (policy/workspace/secret CRUD,
BYOI), and the approvals view renders per-kind —egress_domainapprovals
a member can decide,credential/tool_callones they can only view.
Getting Started gained a Kubernetes-runner flavor (source-honest copy for
what the k8s substrate does and doesn't support yet). -
Signed, published release images.
.github/workflows/release.yml
builds and pushes the four images a release ships (wardynd,
wardyn-proxy,agent-claude-code,agent-codex-cli) to
ghcr.io/cjohnstoniv/<name>on avX.Y.Ztag, cosign-signs each keylessly
(Fulcio/Rekor via the Actions OIDC token), and publishes a CycloneDX SBOM via
the existingmake sbomtarget as a downloadable workflow artifact
(deliberately not auto-attached to the GitHub Release — RELEASING.md's
release step is manual by design; attach it by hand if wanted). linux/amd64
only today. -
CLI confinement-tier aliases +
/healthzfriendly names. The run
commands accept--confinement fence|wall|vaultas aliases for CC1/CC2/CC3
(with trust-model flag help), and/healthznow exposes a
confinement_namesCC-code→friendly-name map (mirroring the console's
cc-meta.ts) so a scriptable consumer learns "CC1" means "Fence" without
hardcoding it (internal/api/server.go,commands.go,types.go). -
Sandbox image builder setup check (
env_builder)./setup/statusnow
reports whether the per-run image builder is wired — the path a
devcontainer build or a--image(BYOI) run needs. INFO (never a warning)
when off, the bare-binary default, so a--image/devcontainer run that
would otherwise silently no-op reads as a real, fixable checklist row. -
Non-blocking model-resolution warning. A codex or managed-subscription
run whose model access resolves ambiguously now surfaces an advisory
warning (resolveRunLLMAccess/runNeedsModelWarning,runs.go) instead of
failing opaquely at dispatch.
Changed
-
New run asks for what the run mode actually needs. An interactive agent run
no longer shows a Task box: the server ignorestaskfor one, so the prompt
the operator typed there was never read by anything. It asks what to start with
instead. A batch run asks for the task; a shell command asks for the command
and no longer offers "Interactive" at all — that combination silently dropped
the command, because the server ignorestask_modefor an interactive run.
Launch is now disabled until the form is complete, and says what it is waiting
for; previously the screen had no client-side validation at all. -
The Getting Started funnel is 10 steps, not 12. The Directories & repos
and Base images steps are gone; "Your work" is one step (Workspaces). The
connection step renders the same two Settings cards rather than embedding the
whole Integrations page. -
A fresh install opens on Getting Started.
/redirects to/setupwhen
the server reports no runs and this browser has never finished the funnel.
Every other route stays directly reachable — this is not the old first-run
gate, which redirected everything until setup was complete. -
examples/policies/composer-dev.json→claude-llm.jsonand
composer-dev-subscription.template.json→claude-subscription.template.json.
Same ceilings, names that no longer point at a deleted feature. -
WARDYN_COMPOSER_CONFIGis no longer read, written or passed through
(scripts/up.sh,deploy/compose/). Nothing in the binary had consumed it
since the composer was cut. -
Integrations are now base components: one
kindfield plus
secrets[]/egress[]/config{}. The stored Category/Type split
is gone —kindis one of the closed set (anthropic_api_key,
anthropic_subscription,bedrock,openai_api_key,
github_app,git_host)
whose row carries its whole contract: each secret names its store ref and
its delivery (proxy_header— never resident),egressis where the
system lives, andconfigkeys are validated per closed kind (an unknown
key 400s by name; bedrock's lane key is nowauth_lane). Stored
pre-base-component rows are folded forward at read time (one-way,
write-new — old documents stay readable; writes emit only the new shape),
and legacyartifact_mirror/host_proxyrows are dropped from this
surface by the fold — their configuration lives under Corporate network.
GET /api/v1/integrationsandPUT /api/v1/integrations/{id}speak the
new shape only.Migration note. Two write-time rules are stricter than what the old
shape stored, so a legacy row may need one edit before it re-saves:- a generic-kind secret row must state a
delivery(the row IS the
contract); a closed kind may still omit it, meaning its own bespoke
transport carries that secret; delivery.modemay only beproxy_header, and a row may carry at most
one such secret. The resident modes are refused rather than stored:
Wardyn has no generic lane that materializes a named secret into a sandbox
path or env var (the resident lanes that do exist —git_host's SSH key,
Bedrock's AWS env — are per-provider and declare no delivery at all), and
the proxy injects one credential header per host, so a second
proxy_headersecret would be silently dropped at dispatch. Split it into
its own integration.
- a generic-kind secret row must state a
-
The run-time integration fold is now one base-component fold with two
exceptions. An api-key AI provider and a generic connection take the same
path: the row's proxy-header secret becomes oneapi_keygrant, and its
egressjoins the run's allowlist.anthropic_subscriptionandbedrock
keep their own transports (an OAuth mount/inject lane; SigV4 via
WorkspaceBedrockRef) because their credential genuinely is not an HTTP
header. Injection is role-agnostic — a secret's declared delivery is what
makes it presentable, never the name of its role — and still applies only
where a workspace, a redirect, or a run actually NAMES the integration:
configuring one grants nothing by itself. -
A Bedrock integration's
region/modelnow win over the boot flags on
the Integrations surface, matching what dispatch already did
(resolveBedrockAuth: a selection wins only the fields it sets, with
WARDYN_BEDROCK_*as the fallback). A wizard-completed Bedrock row on a
deployment that never set those env vars reportedneeds_setupforever
while its runs authenticated fine. -
Integrations no longer install tools; the tool side of the integration
concept is removed. An integration is a connection — secrets + egress —
and never decides what is installed in an image. Concretely: naming an
anthropic_*integration no longer conditions theclaude-codebake;
instead every Wardyn-generated recommended image now carriesclaude-code
unconditionally as standard tooling (like git or curl — the same
checksum-verified native install,genStandardToolsin
internal/workspacescan/gen.go). Repo-own devcontainers and BYO/registry
images stay verbatim — never injected into. The image cache key is salted
(v2), so every previously built workspace image rebuilds once on next
use — pre-change images may lack the now-standard CLI and are never
trusted. (docs/OPERATIONS.md "Every generated image carries the
claude-code CLI as standard tooling".)
Removed
- BREAKING — generic integration kinds.
PUT /api/v1/integrations/{id}now
answers 400 for any kind outside the closed set (anthropic_api_key,
anthropic_subscription,bedrock,openai_api_key,github_app,
git_host), and the error names what is accepted. Generic kinds — package
feeds, container registries, cloud providers, data stores, MCP servers, work
tracking, observability, "other service" — were the operator-extensibility
surface behind the Integrations catalog, and that catalog is gone. A row
stored under an earlier release is not destroyed: it still deserializes,
still sits inSiteConfig, and is still injected into a granted run by
internal/api/integrations_run.go. It simply cannot be edited through the API
any more. - BREAKING —
azure_openaias an integration kind. Its one capability
powered the AI Run Composer, which was also removed; no agent tool can be
pointed at an Azure OpenAI deployment. Anazure-openai-keyleft in the
secret store is untouched and inert. - BREAKING —
pkg/client'sCreateRunRequest.ComposeSessionID, with its
server-side UUID validation andrun.createaudit stamping. The only thing
that ever produced a real value was the composer, so the field had become one
that accepted any UUID and correlated it to a conversation that can no longer
exist. - The
/integrationspage (and/integrations/:id). Both redirect to the
new/settings. Connections are four cards there — Host, Model provider, Git
host, Your SSH keys — each a radio group over concrete lanes, replacing a
catalog of seven kinds plus a generic escape hatch and a 931-line Add dialog. - The integration verification-probe framework:
POST /api/v1/integrations/{id}/test,IntegrationProbe,IntegrationProbeStatus
and the in-memory probe cache. Settings states what is STORED and says so
plainly rather than dialing the provider; a real run is the real test. A
probekey on a row stored under an earlier release is ignored, not rejected. POST /api/v1/integrations/{id}/adopt. Adoption promoted a derived row
into a stored one so the catalog could edit it. APUTonto a derived id used
to answer 409 pointing at that route — a dead end once it was unregistered —
so the write IS the adoption now, carrying the same audit event.
Fixed
-
The live e2e suite no longer calls a deleted route.
test/e2e/live
(-tags docker) still POSTed/api/v1/runs/compose, so its composer sub-test
would have 404'd on the next run. It is daemon-gated and not part of
make ci, so nothing caught it. -
The Settings Git host card validates the host before storing a credential.
Without it a shape-invalid host stored its secret undergit-pat-<slug>and
only failed later when thescm_hostswrite was rejected — leaving a
credential saved under a name nothing would ever read. -
~180 additional go-live findings across the first-run/setup funnel, the
new-run flow, workspaces, integrations, approvals, recordings, and the
audit/policy/secrets screens — broken promises, misleading copy, dead ends,
and WCAG 2.1 AA accessibility gaps (keyboard operability,aria-current
/aria-pressed/aria-labelon custom controls, theme-invariant contrast on
the terminal player, and destructive-action confirmations). Documentation and
threat-model claims were reconciled against the shipped code throughout. -
ssh.forwardaudit rows survived a killed session. A client that
killed its whole SSH session mid--L-forward could race
handleSSHConn's connection-teardown context cancellation against
handleSSHDirectTCPIP's own trailingssh.forwardaudit write — caught
live by the SSH e2e's-Lforward step. The write now runs on the
daemon-lifetimeBaseCtxinstead of the connection's own (soon-cancelled)
context, the same fix already applied to the shell path'ssession.detach
write; the identical latent bug in thessh.exec/ssh.sftptrailing
writes was fixed alongside it. Pinned by
TestSSHGateway_ForwardAuditSurvivesKill. -
The wizard's Build step showed only a bare spinner — the real image-build
output went solely to wardynd's own log, invisible to whoever triggered
the build.handleBuildWorkspace's goroutine now threads a bounded
per-workspace log ring (buildTracker.Log, 500 lines, oldest dropped)
throughresolveWorkspaceImageinto theapi.ImageBuildercall as an
explicitlogSink io.Writer; the wardynd docker adapter tees it with the
existing slog sink so operator logs keep receiving every line unchanged.
GET/POST /workspaces/{id}/buildnow carrylogin the response, and
step-build.tsxrenders it in a scrollable pane that stays up through the
done/failed states too — the failure line plus the log is the debugging
story. -
Editing an onboarded workspace through the "Edit source…" dialog could
silently destroy it. The legacy single-form edit dialog rendered blank
for any multi-source workspace, and its save path submitted the
deprecated scalar shape — whichdecodeWorkspaceRequestfolds into
exactly ONE source, collapsingsources[]and wiping
Requirements/Profile/ApprovedEgress on save.AddWorkspaceDialogis
retired; the "Edit workspace…" kebab item (workspaces.tsx and
workspace-detail.tsx) now opens the same wizard used for onboarding,
hydrated from the row (sources, base image, requirements) and landed on
whatever step the workspace hasn't cleared yet, saving through the
composition-shapesources[]/base_imagePUT the wizard's own Base
image step already used. -
An outside click or Esc could strand a half-onboarded workspace
mid-wizard with no way back. Most steps (including Build) have no
explicit Close button, and dismissing the dialog never deleted anything
server-side, so a stray outside-click or Esc left the operator locked out
of a workspace they'd started onboarding.WorkspaceWizard's
DialogContentnow blocks outside-click and Esc dismissal once a
workspace exists and the step isn't Done — the same condition its own
footer note already warns about. The X button stays a deliberate
one-click close either way, and the "Edit workspace…" fix above gives the
operator a way back regardless. -
Recording-replay CSP (
script-src 'wasm-unsafe-eval'). The asciinema
WASM replay player callsWebAssembly.instantiate(), which a bare
default-src 'self'CSP refuses — the player renders its chrome but never
plays (duration stuck at--:--).script-srcnow adds'wasm-unsafe-eval'
(WASM compilation ONLY — notunsafe-eval, no JSeval/Function), so
replay plays while scripts stay locked to same-origin. -
FAILED-run reason surfaced. A run that ends in FAILED now carries a
human-readable reason instead of a bare terminal status. -
scripts/ci-run.shteardown. The CI one-shot now tears its compose
stack down cleanly on exit. -
WARDYN_LOCAL_MODEbypass under preserved OIDC.scripts/up.shnow
warns when local-mode would silently bypass a still-configured OIDC backend
(preserved config), and documents the registryPORT=0caveat.
Documentation
- Positioning pass (W3). README/ARCHITECTURE/OPERATIONS/TRY-IT and the
threat model now surface the v0.5 moat honestly: thewait_for_review
in-flight connection hold vs. the Enterprise-only analog in Vault/Teleport,
the Apache-2.0 no-paid-tier + audit-completeness framing, and the L1/L2
metadata-server defense-in-depth. - Kubernetes platform requirements. The Helm chart README's
Prerequisites now state the full platform contract in one place:
Kubernetes 1.20+, Helm 3, a NetworkPolicy-enforcing CNI (verified by
the boot-time egress canary, which refuses a non-enforcing substrate),
Postgres 12+, and the optional RuntimeClass (CC2/CC3) and OIDC add-ons.