Skip to content

v0.5.0

Pre-release
Pre-release

Choose a tag to compare

@cjohnstoniv cjohnstoniv released this 18 Aug 06:12
· 606 commits to main since this release

Security

  • 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's inline_policy llm_inspection
    block is now clamped under the default ceiling (its detector_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=true no longer silently disables a configured OIDC/RBAC
    deployment (boot refuses the contradiction); a client-settable run.Task can
    no longer forge the operator-reserved harness-login path; SSH ssh.auth
    success is audited only after signature verification, not at key-offer time;
    an empty-ceiling github_token repo 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's deriveRole). 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/isOperator gate on the session's
    role instead of re-checking WARDYN_OIDC_OPERATOR_EMAILS directly (the
    allowlist still works — it feeds role derivation via LegacyAdminEmails, one
    source of truth instead of two). GET /metrics now carries the admin gate
    explicitly. A member is scoped to their OWN runs/approvals on GET /runs,
    GET /approvals (unscoped), and GET /audit (?run_id= of an owned run,
    else an empty result — never a cross-user leak); GET/kill/profile/grants on
    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/status redacts
    operator-diagnostic detail (environment checks, resident CLI detection,
    secret names, runner detail) for a member. A member's inline_policy on
    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.denied audit 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 per interactive_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 — set seed_auto_tools to 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 manual with an
    in-sandbox relay (wardyn-toolgate, a stdio MCP permission-prompt tool)
    that raises each gated tool use as a tool_call approval — 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); hold is 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}/approve and /deny accept
    decision_scope (plus decision_expires_at for until) on an
    egress_domain approval; wardyn approve/wardyn deny gain --scope/
    --until, the SDK gains DecisionOpts (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
    stays run, today's original behavior, held in the proxy's per-host cache
    for the rest of the run. once releases a single connection (one CONNECT
    tunnel on HTTPS; one request on plain HTTP) and is spent on first use;
    until is the same, bounded by a decision_expires_at up to 30 days out
    and enforced by the run's own proxy sidecar; always is
    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. New PUT /workspaces/{id}/denied-egress
    (full-replace, mirrors approved-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/runs accepts title and description,
    both persisted on the run (migration 0038_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 run gains --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 like task_mode — carried to the sandbox
    as WARDYN_INTERACTIVE_START and consumed by the image's attach ~/.bashrc,
    so it covers the console terminal and the SSH gateway alike (both go through
    the same Runner.Attach). Needs an image rebuildmake 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_proxy derivations. The Integrations surface
    no longer synthesizes rows from EgressRedirects/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
    existing substrate.Substrate seam — 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 enforces NetworkPolicy before the substrate will
    start at all, refusing to boot otherwise
    (WARDYN_K8S_ALLOW_UNENFORCED_NETPOL=1 is the loud, logged opt-out). CC1
    out of the box; WARDYN_CONFINEMENT_MAP/the chart's k8s.runtimeClasses
    pin CC2/CC3 to a registered RuntimeClass. Not at parity with Docker yet —
    no BYOI/devcontainer builds, no local_dir mounts, 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=true wires the substrate above into a
    real install: least-privilege Role/RoleBinding + ClusterRole scoped to
    exactly the verbs the substrate issues, a default-deny NetworkPolicy
    extended with apiserver egress and a runs-namespace ingress peer, and a new
    k8s_egress_containment setup check the console surfaces (Enforcing / Not
    enforcing / Indeterminate). test/conformance's conformance-k8s CI 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. wardynd serves ssh <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 own sftp-server subsystem, and
    -L port 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_LISTEN unset — no
    listener, no host key even generated). See docs/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_domain approvals
    a member can decide, credential/tool_call ones 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 a vX.Y.Z tag, cosign-signs each keylessly
    (Fulcio/Rekor via the Actions OIDC token), and publishes a CycloneDX SBOM via
    the existing make sbom target 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 + /healthz friendly names. The run
    commands accept --confinement fence|wall|vault as aliases for CC1/CC2/CC3
    (with trust-model flag help), and /healthz now exposes a
    confinement_names CC-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/status now
    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 ignores task for 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 ignores task_mode for 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 /setup when
    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.jsonclaude-llm.json and
    composer-dev-subscription.template.jsonclaude-subscription.template.json.
    Same ceilings, names that no longer point at a deleted feature.

  • WARDYN_COMPOSER_CONFIG is 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 kind field plus
    secrets[]/egress[]/config{}.
    The stored Category/Type split
    is gone — kind is 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), egress is where the
    system lives, and config keys are validated per closed kind (an unknown
    key 400s by name; bedrock's lane key is now auth_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 legacy artifact_mirror/host_proxy rows are dropped from this
    surface by the fold — their configuration lives under Corporate network.
    GET /api/v1/integrations and PUT /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.mode may only be proxy_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_header secret would be silently dropped at dispatch. Split it into
      its own integration.
  • 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 one api_key grant, and its
    egress joins the run's allowlist. anthropic_subscription and bedrock
    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/model now 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 reported needs_setup forever
    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 the claude-code bake;
    instead every Wardyn-generated recommended image now carries claude-code
    unconditionally as standard tooling
    (like git or curl — the same
    checksum-verified native install, genStandardTools in
    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 in SiteConfig, 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_openai as 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. An azure-openai-key left in the
    secret store is untouched and inert.
  • BREAKING — pkg/client's CreateRunRequest.ComposeSessionID, with its
    server-side UUID validation and run.create audit 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 /integrations page (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
    probe key 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. A PUT onto 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 under git-pat-<slug> and
    only failed later when the scm_hosts write 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-label on 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.forward audit 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 trailing ssh.forward audit write — caught
    live by the SSH e2e's -L forward step. The write now runs on the
    daemon-lifetime BaseCtx instead of the connection's own (soon-cancelled)
    context, the same fix already applied to the shell path's session.detach
    write; the identical latent bug in the ssh.exec/ssh.sftp trailing
    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)
    through resolveWorkspaceImage into the api.ImageBuilder call as an
    explicit logSink 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}/build now carry log in the response, and
    step-build.tsx renders 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 — which decodeWorkspaceRequest folds into
    exactly ONE source, collapsing sources[] and wiping
    Requirements/Profile/ApprovedEgress on save. AddWorkspaceDialog is
    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-shape sources[]/base_image PUT 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
    DialogContent now 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 calls WebAssembly.instantiate(), which a bare
    default-src 'self' CSP refuses — the player renders its chrome but never
    plays (duration stuck at --:--). script-src now adds 'wasm-unsafe-eval'
    (WASM compilation ONLY — not unsafe-eval, no JS eval/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.sh teardown. The CI one-shot now tears its compose
    stack down cleanly on exit.

  • WARDYN_LOCAL_MODE bypass under preserved OIDC. scripts/up.sh now
    warns when local-mode would silently bypass a still-configured OIDC backend
    (preserved config), and documents the registry PORT=0 caveat.

Documentation

  • Positioning pass (W3). README/ARCHITECTURE/OPERATIONS/TRY-IT and the
    threat model now surface the v0.5 moat honestly: the wait_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.