api

package
v0.8.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: Apache-2.0 Imports: 86 Imported by: 0

Documentation

Overview

Access / role-mapping CRUD (migration 0051): the console's Getting Started -> People editor over the store half of internal/auth/oidc's RoleMappingSource. Modeled on permissions.go's shape — one read that shows the whole picture, small validated writes, an audit event per write — but with two guards permissions.go has no analogue for: this table decides who derives admin at all, so a bad write here can either silently widen access (a collision with the operator allowlist) or lock the acting admin out of their own deployment (see the posture-flip and lockout guards below).

Agent providers: the org-level policy for WHICH coding agents this deployment offers and how each one reaches its model. This file owns the write boundary — validation, the two endpoints, the audit row — and the predicates every later site reads:

agentProvidersConfigured(sc)       is this install in legacy open mode?
agentProviderFor(sc, agentID)      the row governing this agent, if any
(*Server) agentRosterRefusal(...)  the launch-path refusal, one spelling

The predicates live here, next to the validation that shapes the rows they read, so there is exactly ONE spelling of "is this agent offered" — the call sites (run create, the record launcher, the setup roster) only ask.

What this file does not do, so the seams are findable: it never enforces a row's Mechanism at dispatch (that is enforceConfiguredLLMMechanism's, which reads these rows), never captures a per-user credential, and never renders a console surface.

Per-user API tokens (migration 0045): the THIRD auth branch of the public API, plus the self-service and admin CRUD around it.

The problem it solves: before this, the only non-interactive credential was WARDYN_ADMIN_TOKEN — one shared string, deployment-wide admin, attributable to nobody. Every script and CI job that touched the API shared it, and the audit log recorded "admin-token" for all of them. An api token is the opposite: it belongs to ONE human, carries THEIR role, and is revocable on its own.

The load-bearing property is that authenticating with one is indistinguishable downstream from authenticating with that human's SSO session. apiTokenAuth publishes the identity through withHumanIdentity — the same function the session branch calls — so ownership (AgentRun.CreatedBy), the admin gate (isOperator) and capability grants (capabilitySubjects) all bind to the owning human for free, with no per-feature token awareness anywhere. A token is NEVER the admin identity: minting one requires a verified human in the first place (handleCreateAPIToken), so there is no path by which the shared admin token becomes a per-user one.

#1197: the hold projection (projectHolds) and the pure "could THIS caller decide THIS approval" predicate (mayDecide) the attention rule's row 8 needs. New file: approvals.go is at 985/1000 lines (scripts/check-file-size.sh's cap), and both of these are read-only mirrors of gates decide() itself owns — keeping them beside decide() would invite editing one copy and not the other.

GET /approvals' opt-in ?view= (#1197). Split out of approvals.go, which sits at the file-size gate's cap.

The durable write-back: what a decided approval leaves behind on a WORKSPACE. An `always` decision and a verify-loop approval both edit a workspace the operator may not be looking at, and share one contract: fail silent but audited. The decision already stands when these run, so none may fail the request; every give-up path must leave an audit row instead.

internal/api/approvals_reconcile.go is the BOOT-TIME twin: it replays the same `always` verdicts and calls this file's two direction-specific reject predicates so the two cannot disagree about what a workspace will accept.

Attach-session holder registry + audited take-over.

The problem this exists to fix: attach is a SHARED tmux session. handleAttachWS (attach.go) opens a fresh Runner.Attach per client against the same persistent session, so opening the run page while a `wardyn run attach` holds it from a CLI means two clients silently compete for one PTY — and neither can observe the other. The Redraw button in ui/src/app/components/attach-terminal.tsx exists only to clean up the tmux clamp that competition leaves behind.

The honest version: name the holder, admit the second client READ-ONLY, hand it the PTY in place when the holder leaves, and make displacing them an audited act.

AWS SSO account/role PINNING — the control-plane half of the defect below.

The defect: cmd/wardyn-aws-sso took AccountList[0]/RoleList[0], so a cloud team granting an unrelated SSO entitlement inserted an element at index 0 and silently re-pointed the AWS identity every run in the deployment signed with. The helper now verifies a pin or asks the person; THIS file is why it cannot be talked out of that from inside the sandbox.

Three things live here, and they are one idea seen from three sides:

awsSSOPin / awsSSOPinEnv  the admin's roster pin, carried to the login
                          sandbox as launch env (never as sandbox input)
bindCaptureToPin          the upload's refusal predicate: the blob must
                          agree with the LAUNCH-TIME stamp and with the
                          account the configured Bedrock model lives in
refuseCapture             the audited refusal, so a rejected capture is a
                          row in the trail rather than a 400 nobody sees

Why refuse and not rewrite. The stored blob is baked VERBATIM into every later Bedrock run's ~/.aws/config (awsSSOConfigFileContents, runs_bedrock.go). Rewriting a disagreeing capture would record a session the person never saw and would only move the IAM 403 from capture time back to run time — the finding's own complaint, ten retries deep inside somebody else's terminal.

Stored-credential metadata (credential-storage design CS-6): the sinks stamp when a person's credential was last used for a run, admins read who holds a credential for which model provider, and each person reads their own on provider_access. Every answer comes from the rows' metadata columns — no value, data key or store reference is read to build one.

Where a run's model credential lives, graded once by the server.

An unconditional static claim — "Minted at launch, injected by the proxy. Never written into the sandbox." — is a FALSE ASSURANCE: it is read by the person deciding whether a per-user AWS credential may sit inside a shared-kernel container, at the moment they decide.

So the console stops asserting and starts repeating. This file is the one place that answers "where does this run's model credential live?", and it answers from the lanes that actually RESOLVE (resolveRunLLMLanes -> selectedMechanism), never from the roster's declared enum: under a `shared` row mechanismSatisfied compares only the coarse provider type, so a declared bedrock_bearer row is satisfied by a chain that fell through to the host ~/.aws mount or to resident SigV4 keys — every one of which IS resident. An enum-to-sentence map in the console would have shipped a new false claim in place of the old one.

It is a sibling file rather than more of runs_dispatch_llm_mechanism.go for that file's own reason: nothing here resolves a credential or compares a declaration. It reads a resolved lane and names a consequence.

Delegated run management (#1142): a registered portal acts for the person signed in to it. The portal exchanges that person's own live identity- provider token for a ten-minute delegated token (delegation_exchange.go); this file is the lane that token authenticates on.

The invariant is "no impersonation; delegation is recorded as delegation". The lane publishes the PERSON through withHumanIdentity — so ownership, secrets, drives and the governance ceiling all resolve on them with no per-feature awareness — clamped to the user role, and marks the context as delegated, which does three things no handler has to remember:

  • delegationAllowed confines the token to a fixed list of routes; every other route, including any added later, answers 403 delegation_scope;
  • isOperator and isSecurityOperator refuse a delegated context outright;
  • recordAudit (and audit.DelegationRecorder, for every other writer on the shared chain) stamps data.via = {delegate, grant} on every row the request writes, so the portal is on the record beside the person.

The bounds on the failure rows the device routes write — auth.fail's pair (authFailedLimiter: how fast; auth_failed_coalesce.go: how many of the same thing) applied per stream. The anonymous enrolment route is ONE stream, charged to the process-wide bucket auth.fail pays. Each enrolled device's ingest refusals are a stream of their own, charged to a per-device bucket, so one member's laptop spends only its own budget.

Hybrid enrolment's organisation side (docs/design/0.8/PLAN.md, epic #78): the route mount and the admin surface — mint, list and revoke enrolment tokens, list the enrolled devices, revoke one. The device-facing routes are in devices_auth.go.

The device-facing routes of hybrid enrolment (docs/design/0.8/PLAN.md): all an enrolled laptop's wardynd — a DAEMON, never a person — can reach. The anonymous first-boot exchange, the deviceAuth middleware, audit ingest and the heartbeat all live in this one file on purpose: TestDevices_DeviceRoutesNeverTouchHumanIdentity parses it and fails if it ever names a human-identity publisher, an operator predicate or a request- actor helper. A device request carries no OIDC human, and isOperator's no-human arm reads exactly that as the admin token — so these routes are safe only because they never ask. The admin half (mint, inventory, revoke) is in devices.go.

Directory autocomplete: the HTTP half of internal/directory.

One route, GET /access/directory/search, proxying the configured connector so the console can offer a picker for every "who" field instead of a free-text box. The value of that is not convenience — it is that an Entra `groups` claim carries an OBJECT GUID and an App Role arrives as its manifest value, so a hand-typed "Platform Engineering" binds NOTHING and fails open to whatever the unassigned default is. The picker renders Entry.DisplayName and stores Entry.ClaimValue, which is the whole point of the two fields.

Tier: securityOps, not an open read (routes.go). A directory search discloses org structure — who exists, which groups they are in — and while a security admin assigns governance to these very people (so the disclosure is acceptable and stated), it is not something every authenticated member should be able to enumerate.

If-Match/ETag optimistic concurrency for the whole-document-replace surfaces PUT /permissions/enforcement and PUT /site-config. Their in-process mutexes (handlePutSiteConfig's siteConfigMu, handlePutCapabilityEnforcement's capEnforcementMu) already stop two concurrent PUTs clobbering each other; If-Match adds a CLIENT-side guarantee: a caller that read the document is told 412 rather than silently overwriting a write that landed between its GET and its PUT. A caller that sends no If-Match works as before (additive).

The ETag is a content hash, never a stored version column: no migration, and correct for any writer through the same Get/Put pair (including `wardyn site-config set`), with nothing new to keep in sync.

Governance-profile CRUD (migration 0052): the admin-facing surface over governance_profiles + governance_assignments — named, assignable ceilings and the rows binding them to a user, a group, or everyone.

Modeled on access.go / permissions.go: one read that shows the whole picture, small validated writes, an audit event per write. Two things are specific to this table and neither is optional — the monotone-⊆ bound on a profile's eligible grants (governance_grantbound.go), which stops a profile from MINTING credential eligibility the deployment never provisioned, and the ON DELETE RESTRICT surfaced as a 409, which stops a profile delete from silently widening everyone it bound.

Ceiling helpers beside governance.go's CRUD and resolver: the ADVISORY omission warnings a profile write returns (what the DEPLOYMENT ceiling carries that the profile does not), and the per-request memo that makes every ceiling-bounded seam in one request answer from a single resolve.

Its own file by seam (the file-size gate); no behaviour lives here that governance.go's own doc does not describe.

The structural bound on a governance profile's eligible grants: a profile may NARROW the deployment's credential eligibility, never MINT new eligibility.

Its own file, beside governance.go's CRUD, because it is one pure predicate with a security argument attached, and because the resolve-time re-check will call it from a different site than the write handlers do.

groundtruth_capture.go — what a REVIEWER is told about how much of a capture the kernel actually corroborated.

Two facts get separate caveats: the eBPF sensor is host-wide and its heartbeat is global, so folding it into a per-run caveat lets the host's review-time state stand in for the run's. The capture's own claim comes from the exec/connect/file-write lines the sensor bound to THIS run; the host's state is reported as what it is: review-time state.

Each person's own credential for a model provider: the reserved UID-keyed names it is stored under, the strict read that never falls back to the operator's row, the door that writes it into the caller's own namespace, and rule 8's purge — an address, scheme or kind change, or a deletion, removes every person's credential for that provider. Nothing at run create or dispatch reads these rows yet (MP-7 onward).

Model providers: the admin-written records that say which kinds of model credential this deployment supports, where each one sends requests and which harnesses may use it. This file owns the write boundary every door runs — normalization, validation and the server-owned UID. Configuration ONLY: no credential is ever read or written here, and nothing at run create or dispatch reads these records yet, so a nil block changes nothing.

The model-provider endpoints: GET/PUT /model-providers (admin) and the member-safe projection every person reads on GET /setup/status. The write boundary itself — normalization, validation, UIDs — is model_providers.go's.

People an admin sets up before their first sign-in, and API tokens an admin mints for them (#1157) — the interim path for an install whose members never open the console, until delegation (#1142) lands.

The keying rule is the one sign-in already uses: a person's principal IS the identity provider's `sub`, exact and case-sensitive (oidc's CallbackHandler publishes idToken.Subject and nothing else). So a pre-created person is keyed by that subject, a sign-in attaches to it by subject equality alone, and the email the admin records is an assertion used only to derive the role before the person's own claims exist. An email never attaches anyone: a different person signing in with the same email carries a different subject and lands on a different principal.

Permissioning CRUD: the admin-facing surface over capability_grants + capability_enforcement (migration 0042) and the member-safe read of a caller's own effective set. The resolver these routes feed lives in capabilities.go; this file is only validation + persistence + audit for the four writes plus the two reads.

Per-resource availability ("Available to", user-types design 2.6): the restricted bit on one value of one restrictable kind. Who is listed is the allow rows in capability_grants, written through POST /permissions/grants; this file only turns the bit on and off. capBatch.decide's step 3 reads it.

Launch presets (#1143, migration 0087): an admin-managed, named, versioned bundle of existing POST /runs fields that a launcher names instead of sending the whole spec. The server expands a preset into the equivalent explicit request BEFORE any create-path gate runs, so a preset carries no capability of its own: the caller's ceiling, capabilities, owner-scoped secrets and drive apply exactly as they would to the explicit body.

provider_access.go generalises the single hardcoded AWS-SSO-only grading in modelaccess.go (SetupModelAccess) to every model provider a person is granted (MP-12). It reuses that file's vocabulary and its Bedrock/subscription dispatch siblings' scope and read helpers (provider_bedrock.go, provider_subscription.go) rather than re-deriving them, so a state here and the refusal dispatch gives for the same credential can never disagree.

The Bedrock arms of provider dispatch (MP-9): a run that chose a bedrock_sso or bedrock_bearer model provider reaches Bedrock with the region, model and base URL the provider record names, on its OWNER's own credential — their AWS sign-in (wardyn-provider-<uid>-sso) or their own Bedrock API key (wardyn-provider-<uid>-key), read strictly from their namespace. The kind names the one lane: resolveBedrockAuth's chain, and its host ~/.aws mount and static SigV4 arms (the operator's credentials), are never reached.

The sign-in doors keyed by model provider (MP-13). POST /model-providers/{id}/sign-in launches the login sandbox for a bedrock_sso or anthropic_subscription provider; its capture lands in the caller's own namespace under that provider's UID-keyed name (wardyn-provider-<uid>-sso or -oauth), never the roster's harness name and never the operator's. An AWS sign-in is stored by the sandbox's own helper upload (ssotoken.go, bound to the provider as it read at launch); a Claude sign-in by PUT on the same path, with the setup-token the sandbox printed, bound to that sign-in's own run.

The doors answer only while the model-provider block exists, and POST /setup/harness-login only while it does not: the two never both apply.

Dispatch for a run that chose a model provider, and the per-person Claude subscription arm (MP-8). A chosen provider's arm is the only credential author for its run: none of the legacy lanes (the host ~/.claude mount, the operator's managed token, Bedrock, the operator's api key) credentials it.

The subscription arm serves the run owner's OWN Claude sign-in, stored under wardyn-provider-<uid>-oauth in their own namespace, and nobody else's. The shared-subscription posture (SubscriptionPostureOK) does not apply to it: that posture stops one person's subscription serving others, which this lane cannot do. perPersonSubscriptionPosture replaces it for this lane.

#1197: the attention rule (attention/heldCandidates), its projection onto a page of runs (projectAttention), and GET /me/attention (handleMeAttention). New file, beside approvals_decidable.go, for the same reason: approvals.go and runs_policy.go are both close to scripts/check-file-size.sh's 1000-line cap.

Per-file diff stat for a run's workspace — the Files-changed widget on the run-detail cockpit.

Why not workspacescan: that package is an ONBOARDING scanner (what a repo needs), not a differ, and it reads the host filesystem. This read has to answer "what has the agent changed so far", which is a question about the sandbox's own working tree, mid-run.

Why not the host filesystem: run.WorkspacePath is the HOST directory the workspace is bind-mounted from, so reading it from wardynd would work on docker-on-this-host and silently return nothing on k8s (where the mount is a PVC on another node) — the daemon must never assume it shares a filesystem with the sandbox.

So: one ExecStream into the sandbox, running git. Substrate-agnostic — it works wherever the SSH gateway's exec channels already work.

Sandbox resource usage — CPU, memory, disk written, disk used, process count — for the Sandbox widget on the run-detail cockpit.

WHY NOT a Runner.Resources interface method: that buys a moby ContainerStats path, a k8s metrics-API path (which needs metrics-server installed — often it is not), and two more fakes, all to read numbers the kernel already publishes INSIDE the sandbox. One ExecStream over cgroup v2 + procfs is substrate- agnostic and works wherever the SSH gateway's exec channels already work.

ponytail: cgroup v2 + procfs via one exec, not a per-substrate Runner method. Upgrade path is a Runner.Resources seam if some substrate ever needs a non-exec read (a substrate with no exec at all, say).

The DECLARED-MECHANISM gate: when an org has said HOW an agent reaches its model (SiteConfig.AgentProviders), this is the one place that keeps the lane which actually fired from being a different one — at dispatch (enforceConfiguredLLMMechanism), and at create/Review as the same refusal in 422 form (enforceCreateLLMMechanism).

It is a sibling file rather than more of runs_dispatch_llm.go because it is a real seam: resolveLLMTransport DECIDES a transport by a fixed precedence, and everything here COMPARES that decision against an admin's declaration. The two never mix — nothing in this file resolves a credential, and nothing in the transport file reads a roster. (Inlined it would also put that file over the 1000-line gate, which is how the seam got noticed, not why it exists.)

Dispatch for a run whose deployment configured model providers (multi-provider design §2.5, §2.8): the one gate, the one strip, and the per-kind arms. Once the provider block is non-nil, a model run is credentialed by the provider it chose, from its owner's own credential, or by nothing — never by the legacy lane chain, which serves the operator's credentials. The key and endpoint kinds' arm lives here; the subscription arm in provider_subscription.go and the Bedrock arms in provider_bedrock.go.

Dispatch-time secret resolution: the two phases that read a STORED SECRET's VALUE out of the store and put it somewhere the run can reach — the LLM inspection corpus (in-process, for the detector) and env_secret (the sandbox process environment). Split out of runs_dispatch.go for the 1000-line file-size gate (scripts/check-file-size.sh), joining the llm/mounts/gitbroker/ ceiling halves already split the same way.

They belong together because they are the only two places in dispatch that hold PLAINTEXT, and they share the rules that follow from it: every resolved value is registered with the run's mask registry so a verbatim leak into PTY capture or any audit event is scrubbed; no value ever enters the audit stream (the events carry NAMES only); and each documents its own fail direction — LLM inspection fails OPEN because a missing name costs detection coverage, env_secret fails CLOSED per grant because a silently absent variable surfaces as an unauthenticated API call the agent reports as a task failure.

The opt-in GET /runs query surface beyond ?limit=&offset= (#1197 L1a): view/owner/status/ended_within/include_killed/workspace/q. Split out of runs_policy.go so handleListRuns's own branch (any of these present -> the filtered path; none present -> byte-identical pre-#1197 behaviour) stays a short read.

Package api wires Wardyn's control-plane REST surface (the wardynd binary). It contains ZERO target-specific code (the parity rule): it talks to runners only through the runner.Runner interface, and to identity/secrets/broker only through their contract interfaces. Every security decision fails closed.

Routes: see routes.go for the table. The authoritative, always-current classification of every one of them — which role gate each sits behind — is authz_test.go's chi.Walk-enumerated matrix, which cannot go stale because it walks the router itself.

The SSH gateway: `ssh <run-id>@<advertise-host>` lands in the same tmux session and masked live recorder the web terminal (attach.go) uses. Lives here, next to attach.go, rather than a new package, because the SSH caller must go through the SAME runIsUnrecordable gate and liveMaskWriter the web terminal does — splitting them across packages would let a second caller miss that gate.

Channel-level protocol handling (session requests, exec/sftp/direct-tcpip bridging) is sshgateway_channels.go; this file is connection lifecycle: listener bring-up, the DoS bounds, and public-key auth+authz.

Channel-level SSH gateway protocol handling: the "session" channel's request loop (pty-req/window-change/env/shell/exec/subsystem) and the bridges to the sandbox — Attach for shell, runner.ExecStream for exec/sftp/direct-tcpip. See sshgateway.go for connection lifecycle + auth.

Self-service SSH key management: GET/POST/DELETE /api/v1/me/ssh-keys[/{fingerprint}]. This is the REST counterpart to the SSH gateway: the gateway that AUTHENTICATES against these rows lives in sshgateway.go. Any authenticated human manages their OWN keys (scoped by principal, both at the store and here) — this is deliberately NOT operator-gated, unlike secret/policy/workspace writes: an SSH key is a personal credential binding, not deployment configuration.

Per-user run-detail widget layout. The run cockpit lets a user arrange the evidence widgets; that arrangement is theirs and has to survive a new machine, so it is a server row keyed by principal — ui/src/app/lib/storage.ts is localStorage and is explicitly NOT the answer here.

The UI-sandbox gateway (pillar 4): a governed, ticket-gated HTTP relay from an operator's browser to ONE declared loopback port inside a run's sandbox (a code editor, a dev server), riding the SAME exec lane the SSH gateway's `-L` forward already uses — `socat - TCP:127.0.0.1:<port>` over Runner.ExecStream, wrapped as a net.Conn by execConn. There is no pod-IP or container-IP dial, no NetworkPolicy delta and no new network path out of the sandbox (invariant 3): the sandbox's own network is unchanged, and the bytes travel control-plane → substrate exec → container, exactly like an attach.

It lives on a SECOND LISTENER (WARDYN_UI_SANDBOX_LISTEN, empty = off = no listener, mirroring the SSH gateway), and that is a security control, not a deployment convenience: relayed content is SANDBOX-AUTHORED JavaScript. On the console origin it could read the console's own storage (threat model "Console auth token storage") and drive every admin action the operator can. A second origin is what keeps it out. Boot REFUSES a UI listen address equal to the console's (cmd/wardynd, validateUISandboxConfig).

The listener has exactly ONE authentication mechanism and never falls through to the console's session cookie or admin bearer:

POST /runs/{id}/attach/ticket  (existing, owner-or-admin, single-use, 30s)
  → POST <ui-origin>/__wardyn/bind (console fetch; binds the ticket to
    this browser, uigateway_bind.go)
  → POST <ui-origin>/__wardyn/enter (form: run, app, ticket; or the GET form)
  → cookie wardyn_ui_sess (HttpOnly, SameSite=Lax, Path=/r/<run-id>/<app>/)
  → 302 /r/<run-id>/<app>/<app path>  … every later request rides the cookie

The APP is in the path, and the cookie is scoped to it, because a run may declare up to 8 ui_apps: see uigateway_session.go, which owns the cookie.

Cookies are not port-scoped, so a shared hostname would let sandbox content see console cookies and vice versa: every forwarded request has ALL wardyn_* cookies plus Authorization and ?ticket stripped (and any other cookie the operator's UICookiePolicy strips), and every response has Set-Cookie: wardyn_* dropped (cookie tossing), along with any Set-Cookie carrying a Domain attribute or no name (uiSetCookieAllowed, uigateway_cookies.go). Both directions are pinned by tests in uigateway_test.go and uigateway_cookies_test.go.

No content is recorded. ui.authorize/ui.start/ui.open/ui.close say that a human opened and closed an app; there is no keystroke, screen or page capture on this path, and these actions are deliberately distinct from session.attach so a relay session can never appear in the recording picker as if it were one.

The enter ticket's BROWSER BINDING (#1241). A ticket proves who minted it and for which run; on its own it says nothing about which browser presents it. So any user could mint a ticket for their OWN run and drive a victim's browser through the enter hand-off (a link or an auto-submitted form), landing the victim on a session for the attacker's app — login CSRF. In path mode that app then shares an origin with the victim's own relayed apps.

The binding closes that. Before it submits the enter form, the console makes ONE credentialed fetch to the gateway:

POST <ui-origin>/__wardyn/bind   body: ticket=<token>   (from the console page)
  → Set-Cookie wardyn_ui_bind_<id>=<HMAC(ticket)>; HttpOnly; SameSite=Strict;
    Path=/__wardyn/enter; Max-Age=30

and enter (GET and POST) refuses a ticket whose cookie is not there. Why the cookie lives on the GATEWAY origin: that is the only origin enter can read a cookie from, and a cookie the console origin set never reaches it (different host, and a shared parent-domain cookie would reach every sibling host too).

What stops an attacker's page doing the same bind in the victim's browser:

  • uiBindRefusal: the bind must be a same-site fetch (Fetch Metadata, which page script cannot write) and, when the console's URL is configured, carry the console's own Origin. An attacker's page is cross-site, so it gets no cookie.
  • SameSite=Strict: even a binding planted by a cross-site top-level navigation is never sent on an enter that a cross-site page started.
  • The cookie value is an HMAC under the gateway's session key, so it is worth nothing for any other ticket, and enter clears it on use. The ticket is single-use, so the binding is too.

This is why console and gateway must be SAME-SITE (one registrable domain, same scheme): a cross-site console's fetch cannot set a first-party cookie on the gateway at all, and Open fails with the console's bind error instead.

The UI-sandbox relay SESSION: what the wardyn_ui_sess cookie carries, which URLs it is a credential for, and what is re-checked before it opens another connection. uigateway.go is the transport (enter, relay, dial, launcher); this file is the credential.

Two invariants live here and nowhere else:

  • A session names ONE APP, so it is scoped to one app. The relay path is /r/<run-id>/<app>/… and the cookie's Path is /r/<run-id>/<app>/ — a run may declare up to 8 ui_apps, and a single per-run cookie meant entering the second app REPLACED the first app's session in the browser (one name, one Path), after which the first tab's own requests dialed the second app's port. Per-app Paths let the two coexist; the sess.App check in handleUIRelay is the server-side half, since a browser's path match is the only thing keeping them apart and a non-browser client has none.

  • A session is BOUNDED-STALE, not a frozen bearer. It carries its own issued-at, so the operator's WARDYN_UI_SANDBOX_SESSION_TTL applies to cookies already in browsers (the relay's sibling of WARDYN_SSH_ROLE_TTL), and uiDial re-asserts role/ownership against the freshly-loaded run and asks SessionRevocations before every NEW connection. A cookie in the pre-0.7.4 format has no issued-at to bound or compare, so it fails CLOSED.

The deployment's host-root ceiling over a host_path drive, and the one gate that has to look at the OTHER drive rows.

Split out of user_drives.go because these two are the only parts of that file that reason about the HOST FILESYSTEM rather than about a row: the operator's WARDYN_USER_DRIVE_HOST_ROOTS ceiling, whether any configured root can hold a drive at all, and whether two drives' roots share a tree once symlinks are resolved. The CRUD handlers, their request shapes and their audit rows stay next to each other in user_drives.go.

The admin doors' half of the share bound.

The ADMIN doors — GET /drives, the host_root ceiling on a drive write, and the nesting gate's symlink resolution — run uncancellable EvalSymlinks/stat calls on a server that deliberately sets no WriteTimeout, so a hung hard-mounted NAS would hang them and leak a kernel thread per attempt. They share the member doors' driveShareProbe bound and strand memory. The dead-root rules still live only in runner.UserDriveHostRootCheck; this file decides only HOW LONG to wait. The key is "root:"+root, the member path's key for the same subject (driveShareBindFailure), deliberately: one hung share is one strand, whoever asks, so admin and member callers short-circuit on each other's probe.

User-drive CRUD (migration 0054): the admin-facing surface over user_drives + user_drive_grants — the storage an admin registers and the rows allocating one to a user, a group, or everyone.

Modeled on governance.go, deliberately and almost line for line: one read that shows the whole picture, small validated writes, an audit event per write, and the ON DELETE RESTRICT surfaced as a 409. The two tables answer the same shape of question about the same subject vocabulary, so a reader who has understood one has understood this one — and the two cannot drift on the thing that matters, which is what "who" means.

Two things are specific to this table, and neither is optional:

  1. The env ceiling. A host_path drive's host_root is authored HERE, in the database, and then bound into OTHER PEOPLE's sandboxes. So the write boundary composes two checks: types.ValidateUserDrive for the row's shape and runner.UserDriveHostRootCheck for the deployment's operator/MDM-set ceiling over it. A deployment that sets no roots can author no host_path drive at all — an admin cannot widen the ceiling from inside the product.

  2. A new grant is enabled. The store writes UserDriveGrant.Enabled verbatim, so a zero-valued grant is a PAUSED one. `enabled` therefore decodes as a *bool defaulting true here, at the request boundary, which is the only place that can tell "the client said false" from "the client said nothing" — see userDriveGrantRequest.Enabled.

Four routes stay super-only: the ones that name a host path (host_root) or a cluster storage class — creating, listing, updating, and removing the drive itself. The other three — granting an allocation, revoking one, and previewing whose drive resolves — moved to securityOps in 0.8 (issue #168): none of the three names a host path. The tier argument for each route is written at the mountUserDriveRoutes call that decides it (routes.go).

POST /drives/preview — the ADMIN's answer to "whose drive resolves for these claims, and would it actually mount".

Split out of user_drives_resolve.go when that file crossed the 1000-line ceiling scripts/check-file-size.sh holds, at the seam its own banner already drew: everything here is the preview ENDPOINT (its response shape, its warning, its three gates), and everything left behind is the resolver the launch path and GET /me share with it. The parity guard (TestDriveDoorsAnswerOverTheSameSentinels) reads writeDriveError and driveUnavailableReason out of the resolver file, which is why those two stayed there rather than travelling with the surface that writes one of them.

POST /drives/{id}/reclaim — the one verb in the product that DESTROYS a member's stored bytes.

Its own file rather than a ninth handler in user_drives.go, for the reason user_drives_preview.go is its own file: that one is at the 1000-line ceiling scripts/check-file-size.sh holds, and the allowlist is for pre-existing files only. The seam is also the right one — everything here is the destroy path and its refusals, and nothing else in the family shares them.

WHY A VERB EXISTS AT ALL. Deleting a drive removed its row and left the volume or the claim on the substrate with nothing in the product able to name it afterwards, so the storage a departing person left behind was reclaimed by hand off a `POST /drives/preview` answer or not at all (#166). The verb takes the guess out of it.

WHY IT IS FENCED THE WAY IT IS. Four rails, and each one is load-bearing:

  1. On Kubernetes wardynd does not even hold the verb unless an operator grants it: the chart's Role carries `persistentvolumeclaims: [get, create]` and adds `delete` only under `drives.reclaim.enabled` (deploy/helm/wardyn/templates/rbac.yaml), so a stock install answers the apiserver's own 403 underneath everything below.
  2. SUPER-ADMIN ONLY, like every other /drives route and for a sharper reason: this one is irreversible.
  3. A `drive.reclaim` audit row per attempt — success, refusal and failure alike — naming the drive, the person, the backend, the object and what happened to it. A destructive act with no durable record of WHICH bytes went is not auditable after the fact, because the row it describes is gone from the database too.
  4. NO CONSOLE BUTTON. A destructive confirmation is a screen, and this one has no approved mock (docs/design/CONSOLE-RULES.md's mock-first rule), so the surface is the API and the CLI.

The org switch (`storage.user_drive.disabled`) is deliberately NOT a gate here, unlike on every mount-shaped surface in this family. Turning drives off deployment-wide is exactly when an operator needs to clean the objects up, and a switch that blocked the cleanup would strand the bytes it was flipped to stop using.

User-drive resolution (migration 0054): which drive — if any — belongs to the caller, what its per-user object is called, and what the size beside it actually means.

It sits beside effectiveCeiling (governance.go) deliberately: both answer "what does this deployment say about THIS principal", both resolve through the same capabilitySubjects identity and the same subject vocabulary, and both must fail CLOSED in the same three shapes. Sharing the shapes is what keeps a drive from becoming the one governed surface that quietly falls open when the database hiccups.

The member never names a path. A run request carries only `drive: {enabled, read_only}`; everything below is derived from the authenticated identity, so "mount /home/someone-else" is not a request this surface can express.

The RUN seam for user drives (migration 0054): turning `drive: {enabled, read_only}` on a create-run request into the mount a runner executes, or into the one refusal that explains why there is none.

Its own file, beside runs_create.go rather than inside it, for the reason runs_dispatch_ceiling.go and runs_create_validate.go are their own files: the 1000-line gate, and the fact that a security seam reads better whole than scattered through a handler that is already doing eleven other things.

The three refusal shapes, and why they differ.

The whole design of this seam is one table, and each row is a decision that could have gone the other way:

the profile's door is shut       403 + authz.denied   refuse
the group snapshot is unreadable 403 groups_snapshot_stale
everything else                  422, NO audit        denyUserRunQuota

The 403/422 split is the one denyUserRunQuota already draws and it is about the CALLER, not the severity: a door refusal says "you asked for something you may not have", which is an authorization event a SIEM should see. "No drive is allocated to you" says the caller is perfectly authorized and there is simply nothing to mount — an unmet precondition. Auditing that as authz.denied would fill the denial stream with rows about members who did nothing wrong, which is how a real denial stops standing out.

Enabled but unmountable is an error, not a quiet launch.

Every arm below REFUSES the run rather than launching it driveless. The alternative reads friendlier and is worse: a member ticks "mount my drive", the run starts, the agent works for an hour and writes its output into a container layer that is deleted at teardown. Asking for storage and silently not getting it is how work is lost, so the request is a REQUIREMENT.

Where the resolved mount lives between create and dispatch.

On dispatchParams, next to the governance ceiling's own dispatch-time input (the dispatchCeiling argument dispatchRun requires) — NOT on the run row, and there is no migration for it. Three facts decide that, and the third is the one that closes the question:

  1. The create-then-dispatch window belongs to ONE launch. handleCreateRun answers 201 once the run row exists and hands this mount to the detached finishCreateRunLaunch (runs_create_launch.go), which builds the image and dispatches; nothing in the tree re-dispatches a run later (reconcile FAILS in-flight pre-dispatch runs, it never resumes one). So the grant that resolved here is the grant that launch binds — a revocation landing during the build does not reach it, as below.
  2. Re-resolving at dispatch is IMPOSSIBLE anyway, which is why the ceiling does not do it either. Resolution keys on capabilitySubjects — the caller's OIDC sub, email and group snapshot — and the run row carries only CreatedBy, one principal string. There is no stored identity to re-resolve FROM.
  3. Persisting the request flag on the run row instead would therefore buy nothing readable: a column whose only consumer would have to re-resolve from an identity the row does not carry.

The consequence is stated rather than hidden: revoking a grant does not reach INTO a run that is already dispatching. It takes effect on the next run, which is the same guarantee reassertCeilingDenies gives for a ceiling, and the same one the run's own persisted policy gives for everything else.

The user type as a subject (user-types design §2.3, §7): the caller's one type is matched by capability grants, governance assignments and drive grants beside their user and group identities.

Workspace providers: the org-level policy for WHERE work may come from (git providers and their allowed base URLs) and HOW BIG it may get (the storage ceilings). This file owns the write boundary — validation, the two endpoints, the audit row — and the two PREDICATES every admission site reads:

providersConfigured(sc)      is this install in legacy open mode?
providerFor(sc, cloneURL)    is this repo admitted, and by which row?
effectiveScmHosts(sc)        which git hosts does this deployment admit?

The predicates live here, next to the validation that shapes the rows they read, so there is exactly ONE spelling of the match rule and of the host-claim precedence — the call sites (workspace create, POST /sources, run create, the record/scan launchers, the Build step) only ask.

The Entra lane's half of the workspace-provider write boundary: the rules a row carrying `lanes: [entra]`, an `entra` block or `credential_source: per_user` is held to.

It is a SEPARATE FILE from workspace_providers.go for one reason — scripts/check-file-size.sh — and not because the rules are separable: they run inside validateWorkspaceProviders like every other row rule, on both doors, in one pass.

The SSH lane's path-scoping rule (#380): split out of workspace_providers.go by seam (check-file-size.sh) rather than trimmed for length. sshLaneExceeds PathScope is the shared predicate validateProviderLanes refuses on (the console door, always) and the site-config door grandfathers instead of refusing (F2) — sshLaneWidePastPathRows/logWarnSSHLaneWidePastPath are that door's report-and-warn half, read AFTER validation on the block about to be persisted.

Index

Constants

View Source
const (
	// EntraAuthorityOverrideRefusal is the refusal when the override is set
	// without the acknowledgement. %q is the offending value. Lower-case on
	// purpose (staticcheck ST1005): this is a Go error string returned from
	// fmt.Errorf, not an HTTP/console sentence, and Go convention refuses a
	// capitalised one so it composes when a caller wraps it with %w.
	EntraAuthorityOverrideRefusal = "refusing the Entra authority override %q — " +
		"it re-points the Azure DevOps sign-in's authorization, token and discovery endpoints at a server of " +
		"your choosing, which is a TEST hatch and never a production posture; unset it, or explicitly set " +
		"WARDYN_ALLOW_TEST_ENDPOINTS=true to acknowledge that this deployment is a test deployment"
	// EntraAuthorityOverrideWarn is what a deployment carrying the hatch should
	// log at boot. It opens with a greppable literal, because the thing that
	// must never happen is this posture going unnoticed in an inherited values
	// file.
	EntraAuthorityOverrideWarn = "wardynd: TEST HATCH ACTIVE — the Entra authority override re-points the Azure DevOps " +
		"sign-in's authorization, token and discovery endpoints at this URL. No Microsoft endpoint is contacted. " +
		"This is never a production posture; unset it and WARDYN_ALLOW_TEST_ENDPOINTS on any deployment holding a real credential."
)

The TEST-ONLY Entra authority override, and the boot refusal/warning that gate it. It exists for the reason the AWS SSO endpoint override exists: without it, "a person signs in and their Azure DevOps access is captured" cannot be exercised outside a real tenant, because every endpoint is derived from login.microsoftonline.com and nothing could redirect it at a fake.

It is refused unless WARDYN_ALLOW_TEST_ENDPOINTS is also set — the same two deliberate acts that hatch already requires — and everything a typo could hide (a missing scheme, an embedded credential, a path, a query, a fragment) is refused, so a mistake fails at the door rather than by dialing the wrong place with a real credential.

View Source
const (
	// AWSSSOEndpointOverrideRefusal is the BOOT REFUSAL when the override is set
	// without the acknowledgement. It names both vars because the operator
	// reading it on a crash-looping pod has to decide which one they meant. %q is
	// the offending value.
	AWSSSOEndpointOverrideRefusal = "refusing to start: WARDYN_AWS_SSO_ENDPOINT_OVERRIDE is set to %q — " +
		"it re-points AWS IAM Identity Center at a server of your choosing for the containerized login " +
		"AND for every Bedrock run's credential exchange, which is a TEST hatch and never a production posture; " +
		"unset it, or explicitly set WARDYN_ALLOW_TEST_ENDPOINTS=true to acknowledge that this deployment is a test deployment"
	// AWSSSOEndpointOverrideWarn is the BOOT WARN every boot carrying the hatch
	// logs. It opens with a literal an operator (and scripts/kind-sso-walk.sh)
	// can grep for, because the thing that must never happen is this posture
	// going unnoticed in an inherited values file.
	AWSSSOEndpointOverrideWarn = "wardynd: TEST HATCH ACTIVE — WARDYN_AWS_SSO_ENDPOINT_OVERRIDE re-points " +
		"AWS IAM Identity Center (sso-oidc AND the sso portal) at this URL for the containerized login, " +
		"for every Bedrock run's credential exchange and for dispatch-time token renewal. " +
		"No AWS SSO endpoint is contacted. This is never a production posture; unset it and " +
		"WARDYN_ALLOW_TEST_ENDPOINTS on any deployment holding a real credential."
)
View Source
const (
	ADOEntraCallbackPath = "/api/v1" + adoSignInCallbackRoute
)

adoSignInCallbackRoute is the callback's route inside the /api/v1 group, and ADOEntraCallbackPath is the same route as the ABSOLUTE path a browser is sent back to — exported for cmd/wardynd, which composes the registered redirect URL from it rather than typing the path a second time.

View Source
const AWSSSOSpentTokenRetention = 90 * 24 * time.Hour

AWSSSOSpentTokenRetention bounds how long a persisted spent-token row is kept before the reaper prunes it (store.AWSSSOSpentTokenStore's PruneAWSSSOSpentTokens; the prune call site is cmd/wardynd's reapTickLock, which reuses the lifecycle reaper's existing per-tick advisory lock rather than adding a new timer — mirrors RunSecretGrace's exported-constant shape). AWS issues an SSO-OIDC client registration valid for 90 days from creation; past that the refresh token behind a spent fingerprint could never have been redeemed again anyway (registrationLapsed, above, already refuses a lapsed registration before this map is ever consulted), so nothing is lost by forgetting the row once its registration could not still be live.

View Source
const BedrockPlainHTTPWarn = "wardynd: TEST HATCH ACTIVE — WARDYN_BEDROCK_BASE_URL is plain http://, " +
	"so Bedrock inference traffic (and, in bearer mode, the API key riding it as Authorization: Bearer) " +
	"crosses the network UNENCRYPTED to this target on every model call; it is accepted only because " +
	"WARDYN_ALLOW_TEST_ENDPOINTS=true acknowledges this as a test deployment"

ValidateBedrockBaseURL validates WARDYN_BEDROCK_BASE_URL — the Bedrock DATA-PLANE (bedrock-runtime) override that points a regulated deployment at its VPC/PrivateLink endpoint — and returns the normalized base URL for api.Config.BedrockBaseURL. Empty => ("", nil), byte-identical to today.

It delegates to validateOneLLMGateway rather than growing a second rule set: the seven rules are already exactly right here, including the floor that refuses loopback/link-local/metadata/NAT64 while ALLOWING an RFC1918/CGNAT literal — which is precisely what a PrivateLink endpoint resolves into. Sharing them is also what keeps the two knobs from drifting.

region supplies rule 5's "must not equal the public host" comparison (bedrock-runtime.<region>.amazonaws.com). It is the deployment's GLOBAL region: a workspace's per-run region override cannot be known at boot, and pointing this at the public host of some other region is not an escape — the value is the operator's own either way, and the ceiling is already one data-plane host per deployment.

allowTestEndpoints (WARDYN_ALLOW_TEST_ENDPOINTS) relaxes RULE 1 and nothing else: a plain http:// data-plane host becomes acceptable. It exists for one caller — the kind SSO walk, which points this at test/awsssofake's bedrock-runtime stub so a member's minted role credential is actually SPENT by something. That stub serves no TLS, and this is the SigV4 lane, so no per-run TLS-MITM terminates for it; https:// was not a usable answer.

Refused by default, and the refusal names BOTH variables, for exactly the reason ValidateAWSSSOEndpointOverride does (awssso_endpoint.go): plain HTTP to the model data plane is a test posture, and a posture that dangerous takes two deliberate acts, never one env var. With the acknowledgement unset — every real deployment — this function is byte-identical to before the parameter existed. BedrockPlainHTTPWarn is the BOOT WARN every boot that actually TAKES the plain-http relaxation logs. WARDYN_ALLOW_TEST_ENDPOINTS unlocks two relaxations and only the AWS SSO one was audible; this one re-points the BEARER-mode injection target, so the Bedrock API key rides `Authorization: Bearer` in cleartext on every model call — and it said nothing. It opens with the same greppable literal AWSSSOEndpointOverrideWarn does, and it names the plaintext target, because the thing that must never happen is this posture going unnoticed in an inherited values file.

DRAFT (M2 canon pending)

View Source
const HTTPShutdownTimeout = 15 * time.Second

HTTPShutdownTimeout bounds cmd/wardynd's http.Server.Shutdown, the step that runs before WaitBackground. The two budgets run back to back, so together with the audit sinks' final flush they set the shortest grace period wardynd needs before SIGKILL. deploy/helm's terminationGracePeriodSeconds and deploy/compose's stop_grace_period are 70s for that reason, and TestShutdownGraceCoversTheBudget fails if either falls below the sum.

View Source
const InternalPathPrefix = "/api/v1/internal/"

InternalPathPrefix is the run-token and sensor-bearer surface every proxy and the ground-truth ingest call: the only API routes the internal listener serves.

View Source
const RunSecretGrace = time.Hour

RunSecretGrace is how long a run must have been terminal before its masking corpus is evicted. It is a GRACE, not a retention policy: the late readers secretmask.Registry.Evict names (the audit recorder masking a finalize event, a teardown_error from a retried sandbox sweep, a recording uploaded as the session closes) all run within seconds-to-minutes of the terminal transition, and masking fails OPEN, so evicting too early unmasks exactly the events most likely to quote a credential. An hour buys those readers room while still bounding how long plaintext lives in daemon memory.

Variables

View Source
var (
	// ErrADOEntraNotCaptured: this principal has no stored Azure DevOps
	// sign-in. Distinct from a dead one — nothing was ever there.
	ErrADOEntraNotCaptured = errors.New("no Azure DevOps sign-in has been captured for this principal")
	// ErrADOEntraDeadCredential: the refresh token is gone (revoked, expired,
	// or already rotated away). Nothing Wardyn can do renews it.
	ErrADOEntraDeadCredential = errors.New("the captured Azure DevOps sign-in can no longer be renewed")
	// ErrADOEntraConsentRequired: the app is not consented for what was asked.
	ErrADOEntraConsentRequired = errors.New("access to Azure DevOps requires consent that has not been granted")
	// ErrADOEntraInteractionRequired: a Conditional Access policy wants the
	// human present. A control-plane renewal structurally cannot satisfy it.
	ErrADOEntraInteractionRequired = errors.New("access to Azure DevOps requires interactive sign-in")
	// ErrADOEntraUnavailable: the request did not complete, or completed
	// unusably. The credential is untouched and the next attempt redeems it
	// normally.
	ErrADOEntraUnavailable = errors.New("renewing the captured Azure DevOps sign-in did not complete")
	// ErrADOEntraStoreRefused: the secret store answered and refused the
	// stored sign-in (moved, changed at the store, or Wardyn's access to it
	// revoked). Definitive, unlike ErrADOEntraUnavailable: a store that refuses
	// must stop the run's use of the credential at once (credential-storage
	// design K8, rule 21).
	ErrADOEntraStoreRefused = errors.New("the secret store refused the captured Azure DevOps sign-in")
)

The failure classes a caller answers differently, as sentinels so a caller can switch on them with errors.Is and a log line still names the code the authority sent.

They are DISTINCT because the answers are: a dead credential must be re-captured, a consent refusal needs an admin or a fresh consent prompt, an interaction refusal needs the human at a keyboard on a compliant device, and an unavailable authority needs nothing but a retry, and a store that refuses the stored sign-in must stop its use at once. Collapsing any pair of them turns a transient outage into a fleet-wide re-sign-in.

Functions

func AWSSSOProxyInjectFlagDefault added in v0.7.6

func AWSSSOProxyInjectFlagDefault() string

AWSSSOProxyInjectFlagDefault is the DEFAULT the boot flag advertises, derived from the one constant rather than typed a second time.

It exists because the "one-line flip" was a LIE without it: cmd/wardynd's flag hard-coded the string "on", so with nothing set in the environment the resolver was handed "on" and never consulted the constant at all — flipping awsSSOProxyInjectDefaultOn to false would have shipped the lane ON with every document saying off. The flag now advertises this, so the constant decides the unset case, the -h text and the ENV.md default together.

func AdminTokenPrincipal added in v0.7.3

func AdminTokenPrincipal() string

AdminTokenPrincipal exports adminTokenPrincipal for cmd/wardynd's boot-time validation: a -local-operator value equal to it would collide with this mechanism principal, refusing the SAME operator seat at harness-login that boot just accepted.

func BedrockModelARNNamesNoAccount added in v0.7.3

func BedrockModelARNNamesNoAccount(model string) bool

BedrockModelARNNamesNoAccount reports whether the configured Bedrock model LOOKS like an ARN and yet yields no account — an 11- or 13-digit account field, an uppercase `ARN:`, a truncated paste.

bedrockModelAccount fails OPEN on those, which is deliberate (it is the same answer a bare cross-region profile id gets, and it is what keeps an upgrade from taking capture away from every non-ARN deployment). But failing open on a TYPO is silent: the account check is simply off, at both doors, with nothing to see. wardynd logs this once at boot so "off" is a decision somebody can read rather than a thing nobody notices.

func BedrockSSOPinUnenforced added in v0.7.3

func BedrockSSOPinUnenforced(sc types.SiteConfig, model string) bool

BedrockSSOPinUnenforced reports the configuration on which NOTHING server-side constrains which AWS account and role a sign-in may store: an enabled `per_user` `bedrock_sso` row with no `sso_account_id` pin, AND a configured Bedrock model that names no account (a bare cross-region profile id, or nothing at all).

It is not a defect — the pin is optional on purpose, and a single-account tenant never had this problem — it is the RESIDUAL of the account/role pinning defect above, and the point is that it was inaudible. bindCaptureToPin skips both of its checks on this shape, so the in-sandbox chooser is the only remaining defence and it is code the sandbox controls; the one boot warning that existed (BedrockModelARNNamesNoAccount) fires exclusively when the model LOOKS like an ARN — the typo case, never the bare-id case this file calls "most often" true. Exported for cmd/wardynd's boot warning; bedrockProviderCheck reads it for the console's own row. Warn, never refuse: refusing would take capture away from every unpinned deployment on upgrade.

func EgressSecondHumanEnabled added in v0.7.0

func EgressSecondHumanEnabled() bool

EgressSecondHumanEnabled reports whether the four-eyes egress switch is on.

Exported for exactly one caller — cmd/wardynd's boot-time local-mode check, which warns when the switch is combined with a mode that cannot enforce it. It is a function rather than a second cliutil.EnvBool at the boot site so the env NAME and the truthiness rule keep ONE definition: a boot guard that disagreed with the runtime gate about what "on" means would warn about a deployment that is fine, or stay silent for one that is not.

func LoadPolicySpec

func LoadPolicySpec(path string) (types.RunPolicySpec, error)

LoadPolicySpec reads and validates a RunPolicySpec from a JSON file. Used by wardynd to seed the default policy from examples/policies/default.json.

func NewManagedCredProvider added in v0.2.0

func NewManagedCredProvider(store secretstore.Store, provider string) subscription.Provider

NewManagedCredProvider builds a managed subscription provider over store for a provider id (e.g. "anthropic"). Returns nil when store is nil (managed mode simply unavailable).

func OriginHostReadable added in v0.7.3

func OriginHostReadable(raw string) bool

OriginHostReadable reports whether raw is a serialised origin (or a URL) whose host originHost can read — exported for cmd/wardynd's boot-time WARDYN_OIDC_REDIRECT_URL check, so boot and the guard fail closed on exactly the same shapes rather than on two hand-kept lists.

func ReservedPlatformSecret added in v0.6.0

func ReservedPlatformSecret(name string) bool

ReservedPlatformSecret reports whether name is one of this package's platform-internal reserved keys — the base set both the generic-secrets-API guard and every credential sink build on, so a true here means refused at all of them.

Exported for ONE caller: cmd/wardynd's TestPlatformSecretsAreReservedEverywhere, the only place that can see both this set and the daemon's own platform-key constants (a `package main` cannot be imported, so the test cannot live here).

func ResolveAWSSSOProxyInject added in v0.7.6

func ResolveAWSSSOProxyInject(raw string) bool

ResolveAWSSSOProxyInject reads the kill switch. Exported for cmd/wardynd, which owns every env read.

A value nobody typed (empty, or anything that is not one of the two words) is the DEFAULT rather than a boot refusal: this switch's whole purpose is to be reachable in a hurry, and a deployment that mistypes it should get the documented default plus an ENV.md row to read, not a crash-looping daemon in the middle of an incident. The two words are spelled `on` and `off` — the ENV.md convention every other Wardyn mode switch uses — and `true`/`false` are accepted because an operator reaching for a boolean is not wrong enough to be refused.

func ValidateAWSSSOEndpointOverride added in v0.7.4

func ValidateAWSSSOEndpointOverride(raw string, allowTestEndpoints bool) (string, error)

ValidateAWSSSOEndpointOverride validates WARDYN_AWS_SSO_ENDPOINT_OVERRIDE and returns the normalized base URL for Config.AWSSSOEndpointOverride. Empty => ("", nil), byte-identical to a deployment that never heard of this knob.

allowTestEndpoints is WARDYN_ALLOW_TEST_ENDPOINTS; a non-empty override without it REFUSES BOOT, so this posture takes two deliberate acts. Looser than validateOneLLMGateway only where a TEST endpoint differs: plain http:// is accepted (the fake serves no TLS; proven against the real AWS CLI v2 in test/awsssofake/docker.go) and a loopback/private address is not refused (a ClusterIP is private). A missing scheme, embedded credential, path, query or fragment is still refused, so a typo fails at boot.

func ValidateBedrockBaseURL added in v0.7.0

func ValidateBedrockBaseURL(raw, region string, allowTestEndpoints bool) (string, error)

func ValidateDemoVideoBaseURL added in v0.8.0

func ValidateDemoVideoBaseURL(raw string) (string, error)

ValidateDemoVideoBaseURL validates WARDYN_DEMO_VIDEO_BASE_URL — the base URL an air-gapped deployment re-points the Getting Started demo episodes at, since github.com is unreachable there — and returns its normalized form for api.Config.DemoVideoBaseURL. Empty => ("", nil), byte-identical to today (episodeUrl's hardcoded github.com download URL, and the CSP's two hardcoded GitHub hosts).

It delegates to validateOneLLMGateway rather than growing a second rule set, with publicHost "" (rule 5, "must not equal the public provider host", has nothing to compare against for this knob — an empty publicHost can never equal a host rule 3 has already required to be non-empty, so the rule is a harmless no-op here) and allowPlainHTTP false (no test hatch for this knob): https:// only, no userinfo, non-empty host, no query, no fragment — the same fail-closed-at-boot posture as the model gateways.

func ValidateEntraAuthorityOverride added in v0.7.10

func ValidateEntraAuthorityOverride(raw string, allowTestEndpoints bool) (string, error)

ValidateEntraAuthorityOverride validates a test-only Entra authority and returns the normalized base URL. Empty => ("", nil), byte-identical to a deployment that never heard of it.

The rules are the AWS SSO override's, and loosened in the same two places a TEST endpoint differs from a production one: plain http:// is accepted (a local fake serves no TLS) and a loopback or private address is not refused (an in-cluster Service address is private by construction).

func ValidateEnvToggles added in v0.8.0

func ValidateEnvToggles()

ValidateEnvToggles reads each envToggles switch once, so a value that is neither truthy nor falsey exits 2 at boot, in every auth mode, rather than killing the daemon mid-request later. cmd/wardynd calls it right after flag parsing.

func ValidateLLMGateways added in v0.7.0

func ValidateLLMGateways(anthropic, openai LLMGatewayRaw) (map[string]string, map[string]LLMGatewayAuth, error)

ValidateLLMGateways validates the operator-set internal-model-gateway knobs for both providers (boot posture, control-plane-authored — the sandbox cannot set these) and returns api.Config.LLMGateways (public vendor host -> the gateway's normalized base URL) and api.Config.LLMGatewayAuth (public vendor host -> header/format override, present only for a provider that set at least one of the two). Everything unset => (nil, nil, nil), byte-identical to today. Fail closed on any rule violation (the WARDYN_SUBSCRIPTION_INJECT/agentImagesJSON precedent) — an operator-typed posture that doesn't parse must refuse boot, not silently fall back to the public host or the vendor convention: a malformed value surfacing later at dial time as a confusing upstream error is exactly what this guards against.

Types

type ADOEntraAccess added in v0.7.10

type ADOEntraAccess struct {
	AccessToken string
	Scopes      []string
	ExpiresAt   time.Time
}

ADOEntraAccess is one minted access token and what it may do.

Scopes is THE GRANTED SET THE AUTHORITY REPORTED, and a caller must read it rather than assume it got what it asked for: measured against a real tenant, Entra ignores a narrowing request for the Azure DevOps resource and issues a token carrying every scope the person consented to. The token is therefore the person's own identity with all of their consent, not a per-run capability — what bounds a run is Wardyn's own check in front of the resource. This field is the only honest evidence of what a token can do, and it is what a caller should record.

type ADOEntraConfig added in v0.7.10

type ADOEntraConfig struct {
	// RowID identifies the provider row this capture belongs to. It is part of
	// the stored secret's name, so a deployment offering two Azure DevOps
	// organisations keeps two credentials that cannot overwrite each other.
	RowID string
	// TenantID / ClientID / ClientSecret are the app registration. ClientSecret
	// is optional: a public client authenticates with the code challenge alone,
	// which is the posture a browser-delivered redirect wants.
	TenantID     string
	ClientID     string
	ClientSecret string
	// RedirectURL is the absolute callback URL registered on the application.
	// It must be the control plane's own callback route.
	RedirectURL string
	// Scopes is the row's CEILING: the vso.* scopes this deployment may ever
	// ask for. A sign-in requests these (plus offline access and openid); a
	// later redemption may ask for any subset.
	Scopes []string
	// LoginClientID / LoginTenantID are the application and tenant THIS CONSOLE
	// signs people in with. They are the other half of the 0.7.10 boundary: the
	// subject comparison is only sound when the two tokens come from one app
	// registration, so a row naming anything else is refused by name. Empty
	// values refuse every capture, which is the fail-closed direction.
	LoginClientID string
	LoginTenantID string
	// AuthorityOverride re-points every Entra endpoint at another base URL. It
	// is TEST ONLY and honoured only under AllowTestEndpoints — see
	// ValidateEntraAuthorityOverride.
	AuthorityOverride string
	// AllowTestEndpoints is WARDYN_ALLOW_TEST_ENDPOINTS, the repo's existing
	// acknowledgement that this deployment is a test deployment. Without it an
	// AuthorityOverride is refused rather than ignored.
	AllowTestEndpoints bool
}

ADOEntraConfig is the Azure DevOps Entra app registration one provider row signs people in against. It is passed in rather than read off a row here on purpose: the provider-row type and its validation live elsewhere, and the sign-in needs nothing from them but these fields.

type ADOEntraFailure added in v0.7.10

type ADOEntraFailure string

ADOEntraFailure is the same classes as a machine-readable label, for a caller that puts the class in a refusal body or an audit row rather than branching on it. "" means no failure.

const (
	ADOEntraFailureNone                ADOEntraFailure = ""
	ADOEntraFailureNotCaptured         ADOEntraFailure = "not_captured"
	ADOEntraFailureDeadCredential      ADOEntraFailure = "dead_credential"
	ADOEntraFailureConsentRequired     ADOEntraFailure = "consent_required"
	ADOEntraFailureInteractionRequired ADOEntraFailure = "interaction_required"
	ADOEntraFailureUnavailable         ADOEntraFailure = "unavailable"
	ADOEntraFailureStoreRefused        ADOEntraFailure = "store_refused"
)

func ADOEntraClassify added in v0.7.10

func ADOEntraClassify(err error) ADOEntraFailure

ADOEntraClassify maps an error from this lane onto its class. An error from somewhere else classifies as unavailable — the one class that means "nothing about the credential is known to be wrong", which is the safe reading of an error this function does not recognise.

type ADOEntraSource added in v0.7.10

type ADOEntraSource func(ctx context.Context) (ADOEntraConfig, bool, error)

ADOEntraSource resolves the Azure DevOps Entra configuration for this deployment. nil (the default) means no Azure DevOps sign-in is offered and both routes refuse; ok=false means the same for a deployment whose row is absent or disabled.

A function rather than a struct field so the provider row stays the single source of truth and this lane never caches a stale copy of it.

type ApprovalService

type ApprovalService interface {
	Request(ctx context.Context, req types.ApprovalRequest) (types.ApprovalRequest, error)
	// Decide transitions an approval to decision.State (APPROVED or DENIED —
	// the caller picks; there is no separate approve bool, since
	// types.ApprovalDecision.State already says which). decidedByType is kept
	// as its own parameter rather than folded into ApprovalDecision: it is
	// audit attribution (who/what decided), not a property of the decision
	// itself.
	Decide(ctx context.Context, id uuid.UUID, decidedByType types.ActorType, decision types.ApprovalDecision) (types.ApprovalRequest, error)
	Get(ctx context.Context, id uuid.UUID) (types.ApprovalRequest, error)
	List(ctx context.Context, state types.ApprovalState) ([]types.ApprovalRequest, error)
	// CancelForRun moves every still-PENDING approval of a run that has just
	// reached a terminal state to CANCELLED, returning how many moved per kind. It is
	// part of the terminal cascade, beside identity/broker revocation: an
	// approval whose run has ended is a control that cannot function, and a row
	// left PENDING renders live Approve/Deny buttons in the console. reason names
	// the transition ("run_killed", "run_completed", ...). Idempotent by
	// construction — a second call finds nothing PENDING and emits nothing.
	CancelForRun(ctx context.Context, runID uuid.UUID, reason string) (map[string]int, error)
	// ExpireOne moves one still-PENDING approval to EXPIRED (a no-op once decided):
	// wardyn-toolgate's give-up signal (#811). actor is the withdrawing agent.
	ExpireOne(ctx context.Context, id uuid.UUID, actor, reason string) error
	// CountForRun returns how many approvals a run has raised, in ANY state —
	// the per-run cap handleInternalRequestApproval enforces. A sandbox chooses
	// the hosts it asks about, so without that cap the number of rows one run can
	// create is bounded by nothing.
	CountForRun(ctx context.Context, runID uuid.UUID) (int, error)
}

ApprovalService is the narrow approval FSM surface the API depends on. It is satisfied by package-level wrappers over internal/approval (see wardynd wiring), keeping the API decoupled from concrete storage.

type AuditSpool

type AuditSpool struct {
	// contains filtered or unexported fields
}

AuditSpool durably records audit events whose PRIMARY store write failed, so a security event is never silently lost. The control-plane audit log is the system of record; silently dropping a credential.mint / run.kill / egress.deny event when the database blips would let a run proceed while reporting success — exactly the failure a governance tool must not have.

It is a mutex-guarded append-only JSONL file: intentionally simple, fit for the local-first single-host deployment. A background drain loop (StartDrain, wired from the API server) replays spooled events back into the durable store once it recovers and empties the file, so the queryable audit trail becomes complete again automatically — the write-only sink is no longer operationally inert. A nil *AuditSpool disables spooling (a failed primary write is then only logged).

func NewAuditSpool

func NewAuditSpool(path string) (*AuditSpool, error)

NewAuditSpool opens (creating if needed) an append-only JSONL spool at path. The parent directory is created too (MkdirAll, so an already-existing one is a no-op): the flag's own default is the RELATIVE "./data/audit-spool.jsonl" (cmd/wardynd/boot_flags.go), and a fresh working directory or mount with no "data" subdirectory yet would otherwise fail this open outright, silently disabling the fallback spool exactly when a deploy's own defaults haven't pre-created the path for it.

func (*AuditSpool) Append

func (a *AuditSpool) Append(ev types.AuditEvent) error

Append writes one event as a single JSON line and fsyncs it, so a crash right after a primary-write failure still preserves the event. It returns an error only when even the fallback write fails (the last-resort signal the event is lost).

func (*AuditSpool) Drain added in v0.3.1

func (a *AuditSpool) Drain(ctx context.Context, rec audit.Recorder, batch int) (int, error)

Drain replays up to batch spooled events into rec (the DURABLE store recorder), removes exactly those it confirmed and returns the count. On a replay error it stops and keeps every unconfirmed line, so a down store leaves the spool to retry. A line rejected spoolPoisonAttempts times running moves to the quarantine sidecar (fsynced there first) and Drain returns errSpoolLineQuarantined, so the move is never silent. Known limit: two ADJACENT unacceptable lines still wedge (the second reads as an outage, the price of never quarantining during one); the suspect is logged by event id every tick, and docs/OPERATIONS.md has the remedy. rec MUST NOT be the spooling chain: Drain holds the spool lock throughout (safe against concurrent Append; the bounded batch keeps the hold short), so re-entering Append would deadlock. At-least-once, bounded by the in-flight batch (saveSpoolCursor fsyncs at every advance). Not `ON CONFLICT (id)`: audit_events has no unique id (42P10), and a unique index cannot be a boot migration without disabling what db.AuditDDLProtected verifies. A duplicate is the benign direction; exactly-once would be an out-of-band CREATE UNIQUE INDEX CONCURRENTLY.

func (*AuditSpool) Lines added in v0.6.0

func (a *AuditSpool) Lines() int

Lines reports how many events are sitting in the spool right now — the audit backlog a store outage builds up, and the count that has to drain back before the queryable trail is complete again. Served as the wardyn_audit_spool_lines gauge on /metrics: a spool that never returns to 0 is a drain that is not working, which nothing else on the scrape surface shows. A nil spool (spooling disabled) reports 0 — an observability gauge, not a correctness path.

Takes no lock, on purpose (hence the atomic): Drain holds a.mu across every rec.Record for up to spoolDrainDeadline, and a blocked store is exactly when this gauge matters. Waiting on the mutex would push /metrics past Prometheus' 10s default scrape_timeout and lose the ENTIRE scrape, wardyn_store_up included, during the very outage it reports. Briefly lagging the file is fine.

func (*AuditSpool) Quarantined added in v0.7.0

func (a *AuditSpool) Quarantined() int64

Quarantined reports how many lines have been moved aside as permanently rejected. A nil spool reports 0. Served as the wardyn_audit_spool_quarantined_total counter on /metrics — the signal that "the spool drained" no longer implies "the trail is complete".

func (*AuditSpool) StartDrain added in v0.3.1

func (a *AuditSpool) StartDrain(ctx context.Context, rec audit.Recorder, interval time.Duration, batch int)

StartDrain runs Drain on a ticker until ctx is cancelled, replaying spooled events into rec once the store recovers. Each tick drains repeatedly (yielding the lock between batches so Append is not starved) until the backlog clears or a replay error defers the rest to the next tick. It blocks; run it in a goroutine.

func (*AuditSpool) TornDrops added in v0.6.0

func (a *AuditSpool) TornDrops() int64

TornDrops reports how many spool lines Drain has dropped as unparseable (a torn tail from an ENOSPC/partial write). A nil spool reports 0. Served as the wardyn_audit_spool_torn_total counter on /metrics.

type CapState added in v0.5.0

type CapState string

CapState is one capability's current state.

const (
	CapAvailable  CapState = "available"   // wired and usable right now
	CapOff        CapState = "off"         // disabled (integration- or capability-level)
	CapNeedsSetup CapState = "needs_setup" // possible, but a credential/config gap blocks it today
	CapImpossible CapState = "impossible"  // a protocol fact, never fixable by configuration
)

type Capability added in v0.5.0

type Capability struct {
	ID        string   `json:"id"`
	State     CapState `json:"state"`
	Reason    string   `json:"reason,omitempty"`
	Residency string   `json:"residency,omitempty"`
}

Capability is one cell of the matrix: what (ID), current state, why (Reason — populated for needs_setup/impossible, and optionally to annotate an available cell with a qualifying fact), and where the credential lives when wired (Residency; "" when not applicable — e.g. an impossible cell).

type ComponentInfo

type ComponentInfo struct {
	Selected string `json:"selected"`
	// Available lists every implementation self-registered in this build's seam
	// registry (so /healthz truthfully shows what THIS binary can run — e.g. a
	// tagless build advertises sandbox.available=[]). Empty for seams without a
	// registry (policy_engine today).
	Available []string `json:"available,omitempty"`
	Source    string   `json:"source,omitempty"`
}

ComponentInfo describes one pluggable seam's selection for /healthz. Runtime facts only: Selected is ALWAYS the actual running implementation. The recommended-vs-shipped split is prose and lives in docs/PLUGGABILITY.md + ROADMAP.md. Source is "default" or "configured".

type Config

type Config struct {
	// Store is the abstract persistence seam (run/policy/grant/approval/audit
	// CRUD + reads). The control plane talks to this instead of *pgxpool.Pool
	// directly, so a future pure-Go backend can be swapped in. Defaults to a
	// store.NewPG(Pool) adapter when wired from wardynd.
	Store store.Store
	// Identity mints/verifies/revokes per-run identities (embedded by default).
	Identity identity.Provider
	// Approvals is the approval FSM service.
	Approvals ApprovalService
	// ApprovalExpiryAfter mirrors WARDYN_APPROVAL_EXPIRY_AFTER, the deployment's
	// ceiling on how long any request waits for a decision. A run's captured
	// wait (captureRunLimits) never exceeds it. 0 means unknown here. Dispatch
	// also mirrors it onto a hold-mode run's sandbox (approval_expiry.go, RL-1).
	ApprovalExpiryAfter time.Duration
	RunLeaseConfig      // the run lease's settings (run_lease_server.go)
	// Broker mints credentials inside the approval-gated transaction.
	Broker MintBroker
	// GitHubRulesets, when set, lets the setup checklist ask GitHub whether the
	// App is ref-confined on a granted repo. Nil omits that row entirely — which
	// is also what happens when no GitHub App is configured, so the vast majority
	// of deployments never make the call.
	GitHubRulesets RefRulesetVerifier
	// Audit records control-plane-originated audit events. The recorder handed in
	// is the shared masking → spooling → store/fanout chain (see cmd/wardynd), so a
	// failed durable write is masked, logged loudly, and spooled to the local
	// append-only fallback for EVERY writer — the API layer no longer spools itself.
	Audit audit.Recorder
	// AuditSpool is the SAME durable fallback spool the recorder chain appends to on
	// a failed store write (see cmd/wardynd buildAuditChain). When set together with
	// AuditDrainRecorder, New starts a background loop that replays spooled events
	// back into the durable store once it recovers and empties the file, so the
	// queryable audit trail heals automatically. Nil disables the drain.
	AuditSpool *AuditSpool
	// AuditDrainRecorder is the RAW durable recorder (store.Recorder — NOT the
	// spooling chain) the spool drain replays into. It must bypass the spool to
	// avoid a re-spool loop / lock re-entry; a nil recorder disables the drain.
	AuditDrainRecorder audit.Recorder
	// AuditSinkDrops, when set, reports per-sink audit-delivery drop counts for
	// the wardyn_audit_sink_drops_total metric (cmd/wardynd wires it to the audit
	// Fanout's DropsByName). Nil omits the metric — a deployment with no SIEM
	// sinks configured has nothing to report.
	AuditSinkDrops func() map[string]int64
	// Runner launches sandboxes. Nil => headless API-only mode.
	Runner runner.Runner
	// AdminToken gates the public API (constant-time bearer compare). Empty
	// disables the public API entirely (fail closed) except /healthz.
	AdminToken string
	// LocalMode enables local host mode: the public-API auth (humanOrAdminAuth)
	// is bypassed entirely and every admin-gated action is attributed to
	// LocalOperator. This is the single-developer localhost path — no SSO, no
	// token, no Dex. It NEVER affects internalAuth (sidecar/run-token
	// verification), so the sidecar callback path is unchanged. The daemon
	// (cmd/wardynd) refuses LocalMode when bound to an EXPLICIT public IP, but
	// only WARNS (does not refuse) on an unspecified bind (0.0.0.0, the
	// WARDYN_LISTEN default) — operators must bind/publish loopback-only for a
	// real guarantee (the Compose default already publishes 127.0.0.1).
	LocalMode bool
	// LocalOperator is the principal stamped on runs/approvals/audit in
	// LocalMode (e.g. "local:<os-user>"). Ignored unless LocalMode is true.
	LocalOperator string
	// MemberMode mirrors WARDYN_USER_DESKTOP (cmd/wardynd's validateMemberModePosture
	// already enforces its precondition at boot). internal/api did not carry this
	// bit before #378/#379: it exists here so handleHealthz can compute
	// token_login — a member-mode desktop's admin token is a PROCESS credential
	// (deploy/desktop/wardyn.env.m-prime.example), never a human sign-in path, so
	// the console must not offer it as one.
	MemberMode bool
	// SSOOnly mirrors WARDYN_SSO_ONLY: the operator's declaration that SSO is the
	// ONLY way into this console. cmd/wardynd's validateSSOOnlyPosture refuses
	// boot unless that is actually true (OIDC configured, admin token/local
	// mode/member mode/no-operator-list override all absent) before this field
	// is ever set, so handleHealthz's sso_only bit — which the sign-in screen
	// reads to drop the admin-token form and the role-derivation caveat — can
	// never overclaim.
	SSOOnly bool
	// SubscriptionPostureOK reports whether this deployment may resolve a SHARED
	// subscription credential (one operator's live Anthropic OAuth token) into an
	// agent run. Decided once at boot by subscriptionInjectPosture (cmd/wardynd) —
	// false on the k8s runner, false when an OIDC issuer is configured, and false
	// off local mode unless WARDYN_ALLOW_SHARED_SUBSCRIPTION waives that one clause.
	//
	// This is a COMPLIANCE boundary, not only a security one: the harness vendor's
	// terms require each end user to authenticate with their own credential, so a
	// multi-user deployment sharing one subscription puts the OPERATOR in breach.
	// It is enforced at three depths — providers are not constructed at boot,
	// dispatch does not author a sentinel grant, and the injection sink refuses to
	// resolve one. The sink is the load-bearing layer: four other code paths can
	// put a sentinel grant on a run without consulting dispatch (a stored policy
	// naming it, an integration_id, a recorded profile, the managed fallback).
	SubscriptionPostureOK bool
	// SubscriptionPostureReason says WHY injection is unavailable, so the setup and
	// integrations surfaces can explain it instead of rendering identically to
	// "the operator never logged in". Empty when SubscriptionPostureOK is true.
	SubscriptionPostureReason string
	// TrustDomain is surfaced in /healthz and used for run SPIFFE ids.
	TrustDomain string
	// DefaultPolicy is applied to runs created without an explicit policy_id.
	DefaultPolicy types.RunPolicySpec
	// TrustedCAPEM is WARDYN_TRUSTED_CA_FILE's content (cmd/wardynd's
	// loadTrustedCA), read once at boot: a PEM bundle of additional roots a
	// corporate TLS-inspecting middlebox signs with. "" (the default) means the
	// knob is unset. Forwarded verbatim to two other trust boundaries dispatch
	// composes per run — the proxy sidecar (runner.ProxyConfig.TrustedCAPEM,
	// which rides WARDYN_PROXY_CONFIG_JSON) and the sandbox's own CA trust
	// (installSandboxTrustedCA, appended to WARDYN_MITM_CA_PEM) — wardynd's OWN
	// outbound TLS trusts it separately, via installTrustedCA mutating
	// http.DefaultTransport at boot. Control-plane-authored only: never a
	// SiteConfig field, never agent-reachable.
	TrustedCAPEM string
	// LLMGateways maps a public model-provider host ("api.anthropic.com" /
	// "api.openai.com") to an operator-configured internal gateway base URL
	// (WARDYN_ANTHROPIC_BASE_URL / WARDYN_OPENAI_BASE_URL, validated by
	// ValidateLLMGateways). Control-plane-authored, same trust boundary as
	// TrustedCAPEM above — the sandbox cannot set this. nil/empty (the
	// default) => every lane dials the public host, byte-identical to today.
	// Consulted by (*Server).llmProviderFor (the api-key lane's host
	// substitution) and, for Anthropic specifically, by
	// (*Server).anthropicBaseURL/anthropicGatewayHost/anthropicGatewayHostPort
	// (the subscription and Wardyn-managed lanes, runs_dispatch_llm.go) and the
	// subscription-injection host allowlist (injection.go). The harness-login
	// (`claude setup-token`) lane never consults it and always stays on the
	// public host — that flow mints the OAuth token itself.
	LLMGateways map[string]string
	// LLMGatewayAuth maps the same public model-provider host key as
	// LLMGateways to an operator-configured injection header/format override
	// (WARDYN_<VENDOR>_GATEWAY_HEADER / _GATEWAY_FORMAT, validated by
	// ValidateLLMGateways) — independent of whether that provider also has an
	// LLMGateways entry. nil/empty (the default) => every provider keeps the
	// harness catalog's compile-time convention (harness.go's Gateway field),
	// byte-identical to today. Consulted by (*Server).llmProviderFor, which
	// applies Header/Format field-by-field onto the InjectionRule it builds —
	// never onto the mint path directly, so a stored/proposed grant always
	// reflects the resolved convention at proposal time.
	LLMGatewayAuth map[string]LLMGatewayAuth
	// RunnerTarget records which target a run is dispatched to ("docker"|"k8s"),
	// or "none" for a headless control plane (-runner none: runs stay PENDING).
	// Defaults to "docker".
	RunnerTarget string
	// UIDir, when set, serves a SPA from this directory at "/".
	UIDir string
	// ControlPlaneURL is the externally-reachable base URL handed to sidecars
	// (proxy config) so they can call the internal endpoints.
	ControlPlaneURL string
	// ControlPlaneCAPEM is wardynd's internal CA certificate (internal/hoptls),
	// handed to every proxy as the only root it trusts for ControlPlaneURL.
	// Empty only when ControlPlaneURL is loopback http (a local install).
	ControlPlaneCAPEM string
	// ProxyURL, when set, overrides the WARDYN_PROXY_URL injected into sandbox
	// env. Defaults to "http://wardyn-proxy:3128" (the per-run proxy sidecar
	// hostname set by the docker driver). Non-secret: it is a network address,
	// not a credential.
	ProxyURL string
	// RecordingStore, when set, serves PTY session replays under
	// GET /api/v1/runs/{id}/recording/{id} (admin-gated) and accepts uploads
	// via PUT /api/v1/runs/{id}/recording (run-token auth).
	RecordingStore recording.Store
	// OIDC, when set, enables human SSO: it mounts /auth/login,/auth/callback
	// and composes oidc.Middleware in front of the admin-gated API
	// so a valid session cookie OR the admin bearer token authenticates a caller.
	// The admin token still works for the CLI when OIDC is configured.
	OIDC *oidc.Authenticator
	// Directory, when set, backs GET /access/directory/search — the
	// console's autocomplete over the identity provider for every "who" field (a
	// governance assignment's subject, the People-step mapping value), so an
	// admin picks a DisplayName and Wardyn stores the ClaimValue instead of
	// hand-typing a group's object GUID.
	//
	// nil is the ABSENT MODE, not a broken one, and it is the default: the
	// handler answers a distinct 503 (directoryUnconfiguredCode) the console
	// reads as "degrade the combobox to a plain text input". Wired only when
	// WARDYN_DIRECTORY_PROVIDER is set (cmd/wardynd's resolveDirectoryConfig,
	// which REFUSES boot rather than handing over a connector that cannot
	// authenticate). Set, it means this daemon can read the WHOLE directory —
	// stated plainly in docs/OPERATIONS.md, because it is a real expansion of
	// the minimal-reach posture rather than a convenience toggle.
	Directory directory.Directory
	// SessionRevocations is the write side of the revoke-a-human-now
	// lever: handleRevokeSessions calls RevokeSub/RevokeAll on it.
	// oidc.Middleware holds the matching READ side (Config.Revocations, wired
	// by the same cmd/wardynd adapter) — this field is nil exactly when OIDC
	// is unconfigured, and the revoke-sessions route only mounts when it is
	// set (see routes.go).
	SessionRevocations oidc.SessionRevocations
	// OperatorEmails is WARDYN_OIDC_OPERATOR_EMAILS, the legacy admin allowlist.
	// internal/api no longer reads this field directly: requireOperator/isOperator
	// (http.go) gate on the session's derived Role instead. The list still
	// matters — cmd/wardynd feeds the SAME value into oidc.Config.LegacyAdminEmails,
	// so an email on it is still an additional RoleAdmin match at OIDC-login role
	// derivation time (see internal/auth/oidc's deriveRole) — it just flows through
	// Session.Role now instead of being re-checked a second time here. Kept on
	// Config (not deleted) so that wiring keeps compiling; read it here only if you
	// need the raw configured list for display (e.g. an admin-facing settings page),
	// never to gate a request.
	OperatorEmails []string
	// AllowEmailMappings is WARDYN_OIDC_ALLOW_EMAIL_MAPPINGS (Q7 adjudication,
	// docs/design/people-access-prompt.md): the console People-step opt-in
	// gate for an email-shaped (contains "@") role-mapping VALUE on
	// POST /access/mappings. Default false refuses one with EMAIL_KEY_REFUSED
	// (access.go) — an SSO/Entra deployment's default posture steers an admin
	// to an App Role or group key instead. Belongs on api.Config, not
	// oidc.Config: it gates a CONSOLE WRITE, not a boot-time role-derivation
	// input, and env WARDYN_OIDC_ROLE_MAP's own email-keyed entries are
	// UNAFFECTED either way (legacy, still boot-warned separately — see
	// buildOptionalFeatures).
	AllowEmailMappings bool
	// UserMounts is the operator/MDM-set posture for MEMBER-authored local_dir
	// binds (WARDYN_USER_WORKSPACE_ROOTS + _MAP + WARDYN_USER_WRITABLE_ROOTS
	// + _DENY, parsed at boot by runner.ParseUserMountPolicy). The ZERO VALUE
	// — the default — means a member may not onboard a host directory at all
	// (repos and operator-owned workspaces are unaffected), which is the
	// fail-closed posture the section-(c) threat model requires. It bounds ONLY
	// mounts on a member-OWNED workspace; an operator's mounts are never
	// narrowed by it. See internal/runner/member_mount.go.
	UserMounts runner.UserMountPolicy
	// UserDriveHostRoots is the operator/MDM-set ceiling over admin-authored
	// host_path USER DRIVES (WARDYN_USER_DRIVE_HOST_ROOTS, parsed at boot by
	// runner.ParseUserDriveHostRoots). The ZERO VALUE — the default — means NO
	// host_path drive may be authored at all, the same fail-closed posture
	// UserMounts takes above and for the same reason: a drive's host root is
	// authored in the database and bound into OTHER PEOPLE's sandboxes, so its
	// ceiling has to live where a console compromise cannot reach it.
	//
	// A []string rather than the types.UserDriveHostRootCheck closure because
	// GET /drives has to report whether a ceiling is CONFIGURED (so the console
	// can disable the host_path option with the reason rather than offering a
	// save that 422s), which a func value cannot answer. The closure is built
	// from it at the write boundary — see Server.userDriveHostRootCheck.
	UserDriveHostRoots []string
	// ImageBuilder, when set, builds a per-run sandbox image from the
	// devcontainer_repo in a create-run request. Nil disables devcontainer
	// builds (the request degrades to the convention image).
	ImageBuilder ImageBuilder
	// AgentImages, when set, is an agent-name -> OCI image-ref map that
	// overrides the ghcr convention image for named agents. Agents not present
	// in the map fall back to the convention. Validated at server construction
	// (must parse if set); nil disables the override and the convention is used
	// for every agent.
	AgentImages map[string]string
	// AgentAnthropicModel, when set, pins the ANTHROPIC_MODEL env inside a
	// claude-code sandbox (e.g. "opus") so the agent uses a specific model rather
	// than the account/CLI default (which a promo can push to a cheaper model like
	// Fable). Empty = unset; the CLI's own default is used. Applies in both
	// subscription and api-key auth modes.
	AgentAnthropicModel string
	// BedrockRegion / BedrockModel, when BOTH set, opt a claude-code run into the
	// Amazon Bedrock Anthropic transport (CLAUDE_CODE_USE_BEDROCK) instead of the
	// default api-key/proxy-inject path — an enterprise path with no direct
	// Anthropic egress, billed via AWS. BedrockModel is a Bedrock model id (a
	// cross-region inference-profile id like "us.anthropic.claude-..." is what
	// claude-code actually expects, not a bare foundation-model id). Boot-time
	// config only (mirrors AgentAnthropicModel — no live admin write path); the
	// AWS credentials themselves come from the secret store (aws-access-key-id /
	// aws-secret-access-key / optional aws-session-token), read directly at
	// dispatch time because Bedrock's AWS SigV4 request signing can't be
	// proxy-injected the way a static x-api-key header can (see runs.go
	// resolveBedrockAuth). Empty BedrockRegion or BedrockModel disables Bedrock
	// entirely; a subscription-mode run always takes priority over Bedrock.
	BedrockRegion string
	BedrockModel  string
	// BedrockBaseURL re-points the Bedrock DATA plane (bedrock-runtime) at a
	// private VPC/PrivateLink endpoint — the default posture for a regulated
	// estate, where inference traffic must never traverse the public internet.
	// Normalized https base URL (WARDYN_BEDROCK_BASE_URL, validated by
	// ValidateBedrockBaseURL at boot). Empty (the default) => every Bedrock
	// lane dials the regional public host, byte-identical to today.
	//
	// A boot flag, never a SiteConfig field: in bearer mode this value
	// IS the TLS-MITM target and the Authorization-injection scope, so a
	// runtime-writable field would let an admin re-point the operator's Bedrock
	// credential at a host of their choosing with no restart and no boot log —
	// the trust class THREAT-MODEL.md pins as boot-time-only. It is deliberately
	// NOT folded into LLMGateways either: that map carries brokered
	// reverse-proxy semantics (a run's api_key grant targets it and the
	// proxy serves the host over /wardyn/llm/*), while Bedrock is a CONNECT
	// tunnel with SigV4, or MITM + bearer.
	//
	// Ceiling: ONE data-plane host per deployment — the override wins
	// for every region, including a workspace's own region override, so a
	// multi-region estate must not set it. The CONTROL plane
	// (bedrock.<region>.amazonaws.com) is deliberately NOT overridden; an
	// inference-profile ARN is the answer there.
	BedrockBaseURL string
	// AWSSSOEndpointOverride re-points the two AWS IAM Identity Center services
	// (sso-oidc and the sso portal) at ONE server of the operator's choosing —
	// a TEST hatch, and nothing else. It exists so "a member signs in on
	// Kubernetes and their Bedrock run gets per-user credentials" is provable
	// without a real AWS tenant, by pointing the login sandbox, the dispatch
	// egress list and the dispatch-time token renewal at test/awsssofake running
	// on the cluster. See awssso_endpoint.go for the five derivations it moves
	// and why it is neither BedrockBaseURL nor the global AWS_ENDPOINT_URL.
	//
	// Normalized by ValidateAWSSSOEndpointOverride at boot, which REFUSES a
	// non-empty value unless WARDYN_ALLOW_TEST_ENDPOINTS=true; wardynd also
	// WARNs on every boot that carries it. Empty (the default, and every real
	// deployment) => every SSO derivation is byte-identical to a build that
	// never had this field. A BOOT flag, never a SiteConfig field, for the same
	// reason as BedrockBaseURL: a runtime-writable spelling would let an
	// admin re-point a credential exchange with no restart and no boot log.
	AWSSSOEndpointOverride string
	// AllowTestEndpoints is WARDYN_ALLOW_TEST_ENDPOINTS: the only thing that lets
	// a model provider's bedrock.base_url be plain http:// (validateProviderBedrock).
	AllowTestEndpoints bool

	HarnessLoginCPUMillis int // WARDYN_HARNESS_LOGIN_CPU_MILLIS; see harnessLoginResources (harnesscred.go)
	HarnessLoginMemoryMiB int // WARDYN_HARNESS_LOGIN_MEMORY_MIB; ditto — still governance-ceiling capped

	// AWSSSOProxyInject is the kill switch for proxy-side SSO token injection
	// (WARDYN_AWS_SSO_PROXY_INJECT,
	// resolved by ResolveAWSSSOProxyInject at boot): when true a captured-AWS-SSO
	// Bedrock dispatch stages an inert placeholder in the sandbox's token cache
	// and authors a proxy-side injection of the real token onto that run's own
	// portal.sso host, so the session is never resident; when false NEW
	// dispatches stage the real token in the sandbox's own cache instead (a run
	// already dispatched keeps its authored lane until it ends).
	//
	// It is the rollback for an SDK or corporate-MITM surprise without a
	// downgrade. See runs_dispatch_sso_inject.go for the default and docs/ENV.md
	// for the operator-facing contract.
	AWSSSOProxyInject bool
	// BedrockAWSConfigDir, when set, bind-mounts a host AWS config directory
	// (a `~/.aws`) READ-ONLY into the sandbox at /home/agent/.aws, so the AWS
	// SDK inside the run resolves credentials itself — including short-lived AWS
	// SSO / IAM Identity Center sessions, which it refreshes on demand. This is
	// the HOST-MODE alternative to pasting static aws-access-key-id/-secret
	// secrets (which expire under SSO and must be re-pasted): with the mount,
	// `aws sso login` on the host is enough and nothing is stored in Wardyn.
	// It is OFF by default. Host-mode setup.sh auto-detects ~/.aws; the compose
	// stack supports it too via the WARDYN_BEDROCK_AWS_DIR bind (same
	// host==container path, :ro — see deploy/compose/docker-compose.yaml), an
	// opt-in the operator sets explicitly. Because it mounts the operator's
	// ambient cloud credentials into runs, it is a single-user / self-hosted
	// choice, not for a shared multi-tenant service (invariant 1) — the deliberate
	// residency tradeoff already accepted for the ~/.claude subscription mount.
	// Empty = disabled. Takes precedence over the resident static-key path but not
	// over a bedrock-api-key bearer.
	BedrockAWSConfigDir string
	// BedrockAWSProfile, when set, is passed as AWS_PROFILE into the sandbox so
	// the SDK selects a named profile from the mounted config (common with SSO:
	// `aws sso login --profile X`). Only meaningful with BedrockAWSConfigDir.
	BedrockAWSProfile string
	// BedrockAWSSSORegion is the AWS region whose SSO endpoints (oidc.<r>,
	// portal.sso.<r>) the sandbox is allowed to reach so the SDK can exchange an
	// SSO token for role credentials. It often differs from BedrockRegion.
	// Empty defaults to BedrockRegion. Used by the BedrockAWSConfigDir mount path
	// AND by the containerized `aws sso login` (harnessLogin.loginEgress), which
	// has no other way to know which regional SSO endpoints to allow.
	BedrockAWSSSORegion string
	// Secrets is the at-rest secret store. It backs the admin secret-management
	// endpoints (PUT/DELETE/list — values are NEVER readable via the API) and
	// the internal injection-resolve endpoint the proxy calls at startup. Nil
	// disables both surfaces.
	Secrets secretstore.Store
	// MaskRegistry, when non-nil, is used to mask verbatim secret values from
	// PTY capture / asciicast uploads before they reach the RecordingStore.
	// A nil registry disables masking (existing tests stay green).
	MaskRegistry *secretmask.Registry
	// ADOEntra resolves the Azure DevOps Entra app registration the per-user
	// sign-in runs against (see ado_entra.go). Nil — the default — means this
	// deployment offers no Azure DevOps sign-in and both of its routes refuse.
	// A function rather than a value so the provider row stays the single
	// source of truth and the sign-in never acts on a cached copy of it.
	ADOEntra ADOEntraSource
	// SubscriptionToken, when non-nil, yields the operator's LIVE Anthropic
	// subscription OAuth access token from the resident ~/.claude credentials.
	// The internal injection-resolve endpoint uses it to inject a fresh token
	// per request for subscription runs (secret name subscriptionOAuthSecret),
	// so the sandbox holds only an inert sentinel instead of a copy that goes
	// stale. Nil disables the subscription-injection path (falls back to the
	// resident-copy behavior).
	SubscriptionToken subscription.Provider
	// ManagedToken, when non-nil, yields the Wardyn-MANAGED Anthropic subscription
	// token — a long-lived `claude setup-token` the operator captured via the
	// container-login flow, stored age-encrypted. The injection sink resolves the
	// types.ManagedOAuthSecret sentinel through it, exactly like SubscriptionToken
	// resolves the resident-host sentinel. This is what credentials a subscription
	// run in a COMPOSE deployment whose distroless wardynd has no host ~/.claude.
	// Nil disables the managed-injection path.
	ManagedToken subscription.Provider
	// DisableSubscriptionInject is the operator ESCAPE HATCH: when true (env
	// WARDYN_SUBSCRIPTION_INJECT=off), subscription runs keep the legacy
	// resident-copy behavior (the mounted credential, which can go stale) instead
	// of auto-enabling TLS-MITM + injecting the live host token. Default false =
	// the safe proxy-side default whenever a SubscriptionToken provider is wired.
	DisableSubscriptionInject bool
	// AuditCoalesceWindow folds IDENTICAL consecutive auth.fail audit rows —
	// same boundary, reason, path and peer — into the first row plus one summary
	// row carrying count/first_seen/last_seen (env WARDYN_AUDIT_COALESCE_WINDOW,
	// default 5m at the boot flag; 0 or unset = off, one row per refusal exactly
	// as before). It is a maximum GAP between two consecutive identical rows, not
	// a cap on a streak's duration: the flood this exists for was one row a
	// minute forever from a single retrying sidecar, which the 1/sec rate limiter
	// never trips and which still evicted every real security event out of the
	// console's 1000-row window in minutes. See coalesceAuthFailed (http.go) for
	// the bounds that keep a burst from collapsing into one row. The same
	// window folds the device routes' failure rows (device_audit_bounds.go).
	AuditCoalesceWindow time.Duration
	HostCapacityConfig
	// Now is overridable in tests; defaults to time.Now.
	Now func() time.Time
	// OrgFederation is the hybrid audit forwarder's status (cmd/wardynd's
	// bootHybrid), nil when WARDYN_ORG_URL is unset. It feeds /healthz's
	// org_federation block, the wardyn_org_federation_lag gauge and the
	// create-run refusal once the organisation has revoked this device.
	OrgFederation func() federation.Status
	// BaseCtx is the process-lifetime base context used for detached background
	// work that MUST outlive the request that started it — specifically the
	// completion watcher dispatch starts after Exec. The request/dispatch ctx is
	// cancelled when the HTTP handler returns, which would kill a watcher
	// immediately; BaseCtx (threaded from main.go's rootCtx) keeps it alive for
	// the lifetime of the daemon and is cancelled on shutdown. Defaults to
	// context.Background() when unset (the watcher then only stops on process
	// exit).
	BaseCtx context.Context
	// Components advertises, per pluggable seam (identity, secret_store,
	// recording, policy_engine, sandbox, ...), the SELECTED running implementation
	// and the recommended production default, for honest /healthz visibility. Nil
	// => the components object is omitted.
	Components map[string]ComponentInfo
	// AgeKeyDurable: the age key was SUPPLIED (WARDYN_AGE_KEY), not generated at boot, or no local key holds anything (store
	// mode, a key service). False, stored secrets are unreadable after a restart: /setup/status warns. Computed in cmd/wardynd.
	AgeKeyDurable bool
	// SecretStoreExternal names the store every credential is written to in store mode ("Vault at
	// vault.example:8200"), "" in local mode; set, /setup/status shows store_external, not the age-key row.
	SecretStoreExternal string
	// SecretKeyService: the key service wrapping every stored data key ("Vault Transit at host"), or "" for the local key; set, /setup/status shows kek_service.
	SecretKeyService string
	// PlatformKeySeparate: WARDYN_PLATFORM_KEY_FILE gives the boot keys their own local key; false in local mode, /setup/status shows platform_shared (§2.13 c).
	PlatformKeySeparate bool
	// LocalLoopback reports whether the HTTP listen address binds only loopback.
	// It feeds SetupAuth.LocalLoopback so the wizard can explain the local-mode
	// posture. Computed at boot in cmd/wardynd (listenIsLoopback).
	LocalLoopback bool
	// LocalTrustForwarder, when true, tells the LocalMode no-auth bypass to accept a
	// NON-loopback request peer (r.RemoteAddr). It exists for the compose/team
	// deployment ONLY: there wardynd binds 0.0.0.0 inside a container but the host
	// publishes the port loopback-only (127.0.0.1:PORT), so a host UI/CLI request
	// arrives at wardynd from the docker bridge gateway, not loopback. The LAN
	// protection in that topology is the loopback PUBLISH (a LAN peer cannot reach a
	// 127.0.0.1-bound host port at all), not the peer check — so the peer gate is a
	// false positive there. The DNS-rebinding Host gate still applies. NEVER set this
	// for a directly-bound host-mode wardynd on 0.0.0.0: that would re-open the LAN
	// no-auth exposure the peer gate closes. Default false; set by compose only.
	LocalTrustForwarder bool
	// RequireOperatorSetEgress (WARDYN_REQUIRE_OPERATOR_SET_EGRESS, DEFAULT
	// TRUE) makes applyWorkspaceRequirements apply the same provenance gate
	// to a scan_seeded EGRESS requirement that the SECRET side has always applied
	// unconditionally (runs_create.go): only an operator_set requirement is
	// auto-added at launch, and a scan_seeded one — the workspace scanner reading
	// UNTRUSTED repo content — is skipped.
	//
	// Defaulting it TRUE narrows egress for existing workspaces on upgrade, and
	// that cost is deliberate: the secret side calls this exact boundary
	// "security-critical — do not relax", for a reason that applies verbatim to
	// egress — a hostile or simply never-reviewed repo could widen a run's
	// allowlist just by naming a host in a committed file, with no operator ever
	// acting.
	//
	// The upgrade cost is real and bounded: a workspace whose egress
	// requirements are scan_seeded stops having them auto-added, and the run's
	// warnings say which were skipped. The fix is an operator declaring the host
	// (making it operator_set), which is the action the gate exists to require.
	// Set false to auto-add scan_seeded egress requirements again.
	RequireOperatorSetEgress bool
	// DisableGitPATBroker (WARDYN_GIT_PAT_BROKER=off) is the operator escape
	// hatch for the never-resident git_pat lane.
	//
	// With the broker on (the default), a git_pat for a non-GitHub forge is
	// minted PROXY-SIDE and injected on the outbound leg, so the PAT never enters
	// the sandbox — the same posture github_token has always had. Turning it off
	// restores the in-sandbox lane, where the grant id rides the sandbox env and
	// the in-sandbox credential helper mints the PAT into the agent's process.
	//
	// It exists because the broker changes the git TRANSPORT for those hosts (an
	// insteadOf rewrite to a plain-HTTP broker path), and a forge that behaves
	// unexpectedly under that rewrite must not leave a fleet unable to clone.
	// Reverting is a flag flip and a restart, not a redeploy.
	DisableGitPATBroker bool
	// OIDCRoleMapConfigured reports whether WARDYN_OIDC_ROLE_MAP is non-empty —
	// the sso_rbac /setup/status check's gate. Only the presence, never the
	// mapping itself: the API layer has no use for individual entries, only
	// whether the operator has opted into role derivation at all (see
	// internal/auth/oidc's deriveRole). Computed at boot in cmd/wardynd.
	OIDCRoleMapConfigured bool
	// OIDCRedirectURL echoes WARDYN_OIDC_REDIRECT_URL (a URL, not a credential)
	// for the tls_cookie_posture /setup/status check, which flags an https
	// redirect issued while OIDCSecureCookies is still false — the classic
	// behind-an-ingress misconfiguration where WARDYN_TLS_TERMINATED was never
	// set. Computed at boot in cmd/wardynd.
	OIDCRedirectURL string
	// OIDCSecureCookies is the boot-computed secureCookies posture
	// (validateConfig, cmd/wardynd/main.go): true iff wardynd knows the
	// connection is TLS-protected end to end (built-in TLS or
	// WARDYN_TLS_TERMINATED) — the same value threaded into
	// oidc.Config.SecureCookies. Feeds tls_cookie_posture alongside
	// OIDCRedirectURL. Computed at boot in cmd/wardynd.
	OIDCSecureCookies bool
	// BasePath is WARDYN_BASE_PATH ("" = the host root): the prefix Handler
	// mounts every console route under (base_path.go).
	BasePath string
	// ScanAIAdvisor, when non-nil, enables the ADVISORY AI workspace-scan fallback
	// (internal/workspacescan/ai.go): after the deterministic DeriveProfile, when
	// the profile is low-confidence or left unrecognized samples (ShouldAdvise),
	// this gap-fills EMPTY fields only and can only RAISE NeedsReview — it never
	// overrides a deterministic fact and FAILS OPEN (any error keeps the
	// deterministic profile unchanged and the upload still succeeds). Nil (default)
	// = feature OFF, byte-identical to the deterministic-only behavior. Production
	// wires it (from WARDYN_SCAN_AI_ADVISOR) to a workspacescan.AdviseProfile
	// closure; it doubles as the test seam so tests inject a fake instead of
	// shelling out to a real coding-agent CLI.
	ScanAIAdvisor func(context.Context, workspacescan.ScanFacts, workspacescan.WorkspaceProfile) workspacescan.WorkspaceProfile
	// SSHListenAddr is WARDYN_SSH_LISTEN: the address the SSH gateway binds
	// (e.g. ":2222"). Empty = off = no listener, no new surface — ServeSSHGateway
	// no-ops when this is empty, so a caller may invoke it unconditionally.
	SSHListenAddr string
	// SSHAdvertiseAddr is WARDYN_SSH_ADVERTISE: the externally-reachable
	// host[:port] surfaced on /healthz and shown in the run-detail "Connect via
	// SSH" pane's `ssh` command — NOT what wardynd binds to (that's
	// SSHListenAddr), since a container/NAT deployment's bind and reachable
	// address routinely differ. Purely advisory copy; the gateway itself never
	// reads it.
	SSHAdvertiseAddr string
	// SSHHostKey is the gateway's persisted ed25519 host key (loadOrCreateSecret
	// pattern, cmd/wardynd), used to derive the ssh.Signer AddHostKey wants and
	// the fingerprint /healthz discloses ("verify on first connect" — public by
	// design, it identifies the server, it authenticates no one). Empty when the
	// gateway is disabled (cmd/wardynd only loads/generates it when
	// SSHListenAddr is set, so a deployment with SSH off never even mints this
	// secret).
	SSHHostKey ed25519.PrivateKey
	// SSHRoleTTL is WARDYN_SSH_ROLE_TTL: how stale a key's role_checked_at
	// (migration 0046) may be before sshAuth's admin-override path refuses it
	// — the bound on the OIDC-login re-check, since SSH itself has no live
	// session to read a current role from. Zero defaults to a sensible 24h in
	// New (matching the flag's own default), so a Config built without going
	// through cmd/wardynd's flags — every test harness, notably — gets the
	// same posture production does rather than an accidental zero-tolerance
	// TTL that fails every override.
	SSHRoleTTL time.Duration
	// UIListenAddr is WARDYN_UI_SANDBOX_LISTEN: the address the UI-sandbox
	// gateway binds (e.g. ":8081"). Empty = off = no listener, no new surface,
	// mirroring SSHListenAddr. It MUST NOT equal the console's -listen: relayed
	// content is sandbox-authored, and the second listener IS the origin
	// separation that keeps it away from the console's storage (boot refuses —
	// cmd/wardynd's validateUISandboxConfig).
	UIListenAddr string
	// UIAdvertiseURL is WARDYN_UI_SANDBOX_ADVERTISE: the externally-reachable
	// base URL of that listener (e.g. "https://wardyn-ui.example.com"), used to
	// build the enter-URL template /healthz publishes. Advisory copy, like
	// SSHAdvertiseAddr — the gateway binds UIListenAddr, never this.
	UIAdvertiseURL string
	// UIOriginTemplate is WARDYN_UI_SANDBOX_ORIGIN_TEMPLATE: an optional PER-RUN
	// origin (e.g. "https://run-{run}.ui.example.com") for deployments with
	// wildcard DNS. Set, it gives every run its own browser origin — closing the
	// shared-origin residual path mode leaves — and the gateway then REFUSES an
	// enter served on any other host. Empty = shared path-mode origin, where one
	// run's page is separated from another's only by the path-scoped cookie.
	UIOriginTemplate string
	// UISessionTTL is WARDYN_UI_SANDBOX_SESSION_TTL: how long a minted relay
	// session stays usable, and — because the cookie carries its own issued-at
	// — how stale an ALREADY-minted one may be. The UI relay's sibling of
	// SSHRoleTTL: the SSH gateway bounds a stale admin-override stamp, this
	// bounds a stale relay session, and both exist because neither lane has a
	// live console session to re-read a current role from. Zero defaults to
	// defaultUISessionTTL in New (matching the flag's default), so a Config
	// built without going through cmd/wardynd's flags gets the shipped posture
	// rather than a zero TTL that would refuse every session.
	UISessionTTL time.Duration
	// UICookiePolicy is WARDYN_UI_SANDBOX_STRIP_COOKIES: which inbound cookies,
	// beyond the always-stripped wardyn_* namespace, the relay forwards to a
	// sandbox app. The zero value forwards every other cookie.
	UICookiePolicy UICookiePolicy
	// UISessionKey signs the wardyn_ui_sess relay cookie (HMAC-SHA256, >= 32
	// bytes, the loadOrCreateSecret pattern). Nil/short = gateway disabled: a
	// cookie that cannot be signed must never be issued.
	UISessionKey []byte
	// RunConfigKey seals each run's stored proxy config (32 bytes, the
	// wardyn-run-config-key boot key; run_proxy_config.go). Nil: none is kept.
	RunConfigKey []byte
	// DemoVideoBaseURL is WARDYN_DEMO_VIDEO_BASE_URL, validated at boot by
	// ValidateDemoVideoBaseURL (same seven-rule shape as an internal model
	// gateway: https://, no userinfo, no query/fragment). It re-points the
	// Getting Started demo episodes at an operator-run mirror for an
	// air-gapped deployment, where github.com is unreachable — both the
	// download URL episodeUrl's /healthz-reading caller builds and the CSP's
	// media-src this base's host is echoed into (cspMediaSrc,
	// security_headers.go). Empty (the default) = the two hardcoded GitHub
	// hosts, byte-identical to today. Control-plane-authored only, same trust
	// boundary as TrustedCAPEM/LLMGateways above — never a SiteConfig field,
	// never agent-reachable.
	DemoVideoBaseURL string
}

Config holds the API server's non-secret configuration and injected collaborators. All interface fields except Runner are required; Runner may be nil for headless API-only operation (runs stay PENDING with a clear message).

type HostCapacityConfig added in v0.8.0

type HostCapacityConfig struct {
	// HostCapacity refuses every run launch while the host is over its memory
	// or load limits (WARDYN_HOST_*); nil admits everything.
	HostCapacity *hostcapacity.Guard
}

HostCapacityConfig is Config's host-capacity admission, embedded so its wiring lives beside the feature rather than in server.go.

type ImageBuildSweeper added in v0.5.0

type ImageBuildSweeper interface {
	SweepOrphanedBuilds(ctx context.Context) error
}

ImageBuildSweeper is an OPTIONAL capability an ImageBuilder may additionally implement: a boot-time sweep that force-removes build containers orphaned by a crashed or restarted wardynd process. It is a separate interface (rather than a fourth ImageBuilder method) because api must stay target-agnostic — the concrete envbuild package is docker-tagged, so this is checked via type assertion in sweepOrphanedBuilds below, the same optional-capability pattern as store.RunWatcherLeaser. An ImageBuilder that doesn't implement it (nil, a test fake, or a future non-docker target) is simply never swept.

type ImageBuilder

type ImageBuilder interface {
	// BuildDevcontainer builds the devcontainer for repoURL@ref and returns the
	// local image reference to run. outputTag is the deterministic per-run tag
	// the result is committed under. logSink, when non-nil, receives build
	// output lines as they happen (e.g. the wizard Build step's in-memory log);
	// nil preserves the implementation's own default (wardynd's slog).
	BuildDevcontainer(ctx context.Context, repoURL, ref, outputTag string, logSink io.Writer) (imageRef string, err error)
	// BuildFromDevcontainerFiles builds an image from IN-MEMORY generated
	// devcontainer files (relative path -> content, e.g.
	// ".devcontainer/devcontainer.json") rather than a repo checkout, returning
	// the local image reference. It drives the SAME hardened envbuilder path as
	// BuildDevcontainer. Used for an onboarded workspace WITHOUT a wired
	// devcontainer, where internal/workspacescan generates a minimal one from the
	// detected profile. outputTag is the deterministic profile-hash-keyed tag
	// the result is committed under. logSink: see BuildDevcontainer.
	BuildFromDevcontainerFiles(ctx context.Context, files map[string]string, outputTag string, logSink io.Writer) (imageRef string, err error)
	// FinalizeBase wraps an arbitrary USER-supplied base image (Bring Your Own
	// Image) with Wardyn's runner tools + a cleared ENTRYPOINT, returning the
	// runnable local image reference. No untrusted build, no registry push — just
	// the trusted FROM+COPY finalize stage; the base is pulled only if absent, so
	// a host-pre-pulled private image works. outputTag is the per-run tag.
	// logSink: see BuildDevcontainer.
	FinalizeBase(ctx context.Context, baseRef, outputTag string, logSink io.Writer) (imageRef string, err error)
}

ImageBuilder builds a per-run sandbox image from a devcontainer repo. It is target-agnostic (the parity rule): the concrete envbuilder implementation is wired in wardynd behind the "docker" build tag, so the control-plane default build carries zero target-specific code. Nil disables devcontainer builds.

type LLMGatewayAuth added in v0.8.0

type LLMGatewayAuth struct {
	Header string
	Format string
}

LLMGatewayAuth is a provider's operator-set injection header name and value format override for api.Config.LLMGatewayAuth (WARDYN_<VENDOR>_GATEWAY_HEADER / _GATEWAY_FORMAT, validated by ValidateLLMGateways). Header and Format are independent: either may be set alone, and an empty field means llmProviderFor keeps the harness catalog's compile-time vendor convention for that piece — byte-identical to today unless the operator explicitly set one.

type LLMGatewayRaw added in v0.8.0

type LLMGatewayRaw struct {
	BaseURL string
	Header  string
	Format  string
}

LLMGatewayRaw is the raw operator-typed value of one provider's three gateway knobs, exactly as read off the boot flags, before validation.

type MintBroker

type MintBroker interface {
	MintForGrant(ctx context.Context, caller *identity.Claims, grantID uuid.UUID) (broker.Minted, error)
	RevokeRun(ctx context.Context, runID uuid.UUID) error
}

MintBroker is the credential-mint surface the API depends on (internal/broker).

type RecordTaskResult

type RecordTaskResult struct {
	RunID uuid.UUID `json:"run_id"`
	// Label is the operator-chosen session name (e.g. "build & test"). Persisted
	// because sessions are user-named, not derived — the session key is a slug of
	// this, so the label carries the original display text.
	Label string `json:"label,omitempty"`
	Mode  string `json:"mode"` // auto | interactive
	// Confined distinguishes a VERIFY session (default-deny egress, limited to the
	// workspace's approved set + baseline) from a learning session (open egress).
	// Same interactive attach machinery; the flag flips AllowAllEgress and lets the
	// UI list learning sessions on the Record step and verify sessions on Verify.
	Confined bool `json:"confined,omitempty"`
	// LLMMode + Model record the auth the session actually ran with (the operator's
	// configured provider): subscription | api-key | none, plus the pinned model.
	// Saved with the session so it's visible and a verify replays the SAME auth as
	// the recording (the operator's setup, not a re-derived guess).
	LLMMode    string     `json:"llm_mode,omitempty"`
	Model      string     `json:"model,omitempty"`
	Status     string     `json:"status"` // recording | recorded | record_failed
	StartedAt  time.Time  `json:"started_at"`
	FinishedAt *time.Time `json:"finished_at,omitempty"`
	// Observations is the deterministic recordmode.Capture aggregate of what the
	// run ACTUALLY used, computed server-side from its audit events at
	// termination — never from a sandbox upload.
	Observations *recordmode.Observations `json:"observations,omitempty"`
	// Clean is the server-side recordmode.CleanReplay verdict, stamped ONLY for
	// a CONFINED entry (nil for an open recording — the verdict only means
	// something under confinement). Absent (nil) also covers old rows written
	// before this field existed: unknown, not "not clean" — render neutral,
	// never red. Clean means clean for what was replayed: reconcile finalizes
	// on any terminal state, so a replay the operator ends early earns the
	// same verdict as a full one.
	Clean *bool `json:"clean,omitempty"`
	// Caught is the number of observed domains with DenyCount>0 or
	// PendingCount>0, stamped alongside Clean — CONFINED entries only (0/unset
	// for an open recording).
	Caught int `json:"caught,omitempty"`
	// SecretNamesMinted resolves Observations.MintedGrantIDs to the secret /
	// grant names actually exercised, for the "proven used" checklist render.
	SecretNamesMinted []string `json:"secret_names_minted,omitempty"`
	// EgressPromoted marks that at least one of this task's observed hosts was
	// merged into the workspace's `egress:` requirement rows (operator action,
	// never automatic; possibly a subset — the flag is a boolean, not a count).
	EgressPromoted bool `json:"egress_promoted,omitempty"`
	// KernelSensorBlind: the run executed under CC3/Kata where the host eBPF
	// sensor cannot see — proxy decisions were the sole egress signal.
	KernelSensorBlind bool `json:"kernel_sensor_blind,omitempty"`
	// FailureHint explains a record_failed in operator terms (e.g. the sandbox
	// couldn't reach the control plane, so no evidence landed).
	FailureHint string   `json:"failure_hint,omitempty"`
	Caveats     []string `json:"caveats,omitempty"`
}

RecordTaskResult is one task's Record Mode state, persisted opaquely in workspaces.record_results (map taskKey → RecordTaskResult). The api layer owns this shape; the store never interprets it.

type RefRulesetVerifier added in v0.5.0

type RefRulesetVerifier interface {
	VerifyRefRuleset(ctx context.Context, repo string) (confined bool, detail string, err error)
}

RefRulesetVerifier answers whether GitHub itself confines the App's writes on a repo to the run branch namespace. Satisfied by broker.GitHubMinter. It is a SEPARATE, optional Config field rather than a method on MintBroker because it is the only thing in the API layer that calls an external network service, and the /setup/status checklist must degrade to "unknown" — never "fail" — when it is absent or errors.

type RunLeaseConfig added in v0.8.0

type RunLeaseConfig struct {
	// EndedRunGrace mirrors WARDYN_ENDED_RUN_GRACE: how long a run its lease
	// ended keeps its files (stopped, no network) before it is torn down. 0
	// tears a run down at its end.
	EndedRunGrace time.Duration
}

RunLeaseConfig is the run lease's part of Config (run_lease.go), embedded so its fields read as Config's own.

type SCMAccess added in v0.7.10

type SCMAccess struct {
	// State is one of the six modelaccess.go values.
	State string `json:"state"`
	// Source is "org" or "separate" — set for a per-user connection that is
	// live or expired_signin (which door to go back through).
	Source string `json:"source,omitempty"`
	// Cause narrows `expired_signin` (scmAccessCauseEnded,
	// scmAccessCauseConsentNeeded).
	Cause string `json:"cause,omitempty"`
	// Org is the Azure DevOps address this row clones from (the row's first
	// base URL) — the {org} the connect and launch dialogs name.
	//
	// NO ROW ID. workspace_admission.go's disclosure rule ("the kind, never
	// the row id") applies here as it does to admitSSHHostLevel's 201 warning;
	// Org is the one deliberate exception the frozen copy needs
	// (LAUNCH_DIALOG_BODY, CONNECT_DIALOG_BODY, PANEL_ORG interpolate {org}
	// verbatim). Org + Kind is the stable key over the /me/scm-access array.
	Org string `json:"org,omitempty"`
	// Kind is the provider kind this row is ("azure_devops", always, today —
	// scmaccess.go grades Azure DevOps rows only) — paired with Org as the
	// stable key a list of these needs, in place of a row id.
	Kind string `json:"kind,omitempty"`
	// CapabilityCeiling is the row's capability_ceiling: the most any run on it
	// may hold. The policy editor locks every capability outside it, since a
	// policy naming one is refused at launch. Read-only here.
	CapabilityCeiling []adoscope.Capability `json:"capability_ceiling,omitempty"`
}

SCMAccess is THIS PRINCIPAL's Azure DevOps access answer for ONE row — the shape the Getting-started chip, the Settings connected panel, the New Run rail's preflight line, and GET /me/scm-access all read instead of each re-deriving it from the provider row and the stored blob.

Nothing in this file produces `live` with an EMPTY Source, but every reader must still treat that combination as "makes no per-person claim" (§7.5's ACCESS_SHARED_NOTE) rather than assume Source is always present.

type SandboxOrphanSweeper added in v0.6.0

type SandboxOrphanSweeper interface {
	SweepOrphanedSandboxes(ctx context.Context, minAge time.Duration, isOrphan func(runID uuid.UUID) bool) (int, error)
}

SandboxOrphanSweeper is an OPTIONAL capability a Runner may implement: a label-keyed sweep of sandbox containers whose run row no longer owns them. A crash BETWEEN CreateSandbox and SetSandboxRef (runs_dispatch.go) leaves the per-run agent/proxy containers + network running under a run row with an EMPTY sandbox_ref — and every ref-keyed teardown (reconcileOrphanedSandbox, SweepTerminalSandboxes) skips a ref-empty row, so those containers leak across every reboot, untracked. This sweep finds them by the wardyn.run-id label the substrate stamps and tears down any whose run isOrphan, past the minAge dispatch grace. Checked by type assertion (like ImageBuildSweeper) so api stays target-agnostic; a Runner without it (nil, a test fake, a future non-docker target) is simply never swept. isOrphan is called by the substrate with each labeled run id; only api can read run rows to answer it.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the control-plane HTTP server. It is safe for concurrent use.

func New

func New(cfg Config) *Server

New constructs a Server and builds its router. It does not start listening.

func (*Server) CancelTerminalRunApprovals added in v0.7.2

func (s *Server) CancelTerminalRunApprovals(ctx context.Context, runID uuid.UUID)

CancelTerminalRunApprovals is cancelRunApprovals' exported seam for the THIRD terminal writer: cmd/wardynd's idle reaper (lifecycleStopper.StopRun) is the then-only writer of RunState=STOPPED, it lives outside this package, and before this it ran the revoke half of the cascade and none of the approval half — so an idle-stopped run's PENDING approvals sat in the queue until the 24h ExpireStale sweep, logged "nobody answered" rather than "the run stopped", and stayed decidable (an `always` approve replayed into the workspace allowlist for a sandbox that is gone). One helper, three callers; the reason is still read back from the run row, so the reaper passes nothing it could get wrong.

func (*Server) CaptureLoginGrant added in v0.7.10

func (s *Server) CaptureLoginGrant(ctx context.Context, subject string, grant oidc.LoginGrant)

CaptureLoginGrant implements the capture half of oidc.LoginGrantSink: it stores the Azure DevOps refresh token the console login just earned.

THE IDENTITY BINDING IS SATISFIED BY CONSTRUCTION HERE, not skipped. The dedicated sign-in must prove that the id_token it received belongs to the session that started the flow, because those are two different flows. At login they are ONE flow: subject is the verified id_token's own subject, and it is the principal the session is being issued for. There is no second token to disagree with it, so the guarantee is the same one, reached by a shorter road — and the credential still lands under that subject's own namespace and nowhere else.

It reports nothing and refuses nothing. Every early return below is a case where the person is signed in and simply has no Azure DevOps credential yet.

func (*Server) FlushAuthFailedStreak added in v0.7.2

func (s *Server) FlushAuthFailedStreak()

FlushAuthFailedStreak closes whatever streak is still open and records its summary row. Called at daemon shutdown (cmd/wardynd's serveAndShutdown, after httpSrv.Shutdown has stopped accepting requests and before the audit sinks are closed), mirroring the proxy's flushPrivateIPMemo at the same point in its own stop — and for the same reason: a streak closes only on a different key, the 1000-cap, or the 5m gap timer, so a restart during a steady drip lost up to 999 refusals' COUNT. The opening row is already in the trail, so what was lost was only the volume — during exactly the rollout this instrument exists to measure.

The timer could not have saved it either: a streak's AfterFunc records under BaseCtx, and by shutdown BaseCtx is already cancelled. So this records under context.WithoutCancel(BaseCtx) — the daemon's values (the audit recorder reads them), none of its cancellation.

Ceiling, stated rather than implied, same as the memo's: this is the ORDERLY stop. A SIGKILL, an OOM kill or a pod deleted out from under the process drops the open streak's count — never the refusals themselves, each of which opened its streak with a row that is already recorded. So does a shutdown that lands mid-flood with the rate limiter empty: this row pays the limiter like every other summary (recordAuthFailedSummary), and a refused one is counted in the suppressed series instead — the audit trail's bound outranks a count.

func (*Server) GoBackground added in v0.8.0

func (s *Server) GoBackground(fn func())

GoBackground is goBackground, exported for cmd/wardynd's own long-lived background loops that must not be abandoned mid-cycle by a SIGTERM the way a bare `go` would be — today the terminal-sandbox sweep ticker (runTerminalSandboxSweeper, #710): fn is expected to run until its ctx is cancelled and then return promptly, so WaitBackground's wait for it resolves near-instantly once rootCtx is already cancelled (which it always is by the time serveAndShutdown calls WaitBackground), never by hitting backgroundShutdownBudget the way a genuinely wedged request-detached teardown would.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns the console listener's handler: the chi router, mounted under Config.BasePath when one is set. Once the proxy hop is TLS (ControlPlaneCAPEM set) the console answers InternalPathPrefix with the same 404 the internal listener gives console routes: every internal caller is pinned to InternalHandler's listener, so a run token or sensor bearer arriving here came from somewhere that is not one (#1263).

func (*Server) InternalHandler added in v0.8.0

func (s *Server) InternalHandler() http.Handler

InternalHandler returns the chi router at the host root, for the proxy-facing TLS listener. Runs dial that listener directly, never through the operator's reverse proxy, so WARDYN_BASE_PATH does not apply to it.

func (*Server) LoginScopes added in v0.7.10

func (s *Server) LoginScopes(ctx context.Context) []string

LoginScopes implements the request half of oidc.LoginGrantSink: the extra scopes the console login should ask consent for.

It returns the row's whole ceiling plus `offline_access`, never a subset. Consent is the only narrowing that has any effect on this service — a later token request naming a subset is answered with everything consented — so the ceiling asked for here IS the reach the organisation is choosing to grant, and asking for less at login would mean a second consent prompt later for anything left out. `openid` is already in the login's base scopes.

nil on every doubt: no source wired, no row, an unusable row, or a row against another tenant or application. A nil answer leaves the authorization request exactly as it was.

func (*Server) ReconcileOnBoot

func (s *Server) ReconcileOnBoot(ctx context.Context) error

ReconcileOnBoot rebuilds the safety net that keeps a run from stranding non-terminal forever with a live sandbox and un-revoked credentials (C3), then keeps rebuilding it for the life of the daemon. Three parts, because they are safe under different conditions:

  1. finalizeUndispatchedRuns — at boot AND on a slow cadence after it. A non-terminal run older than undispatchedGrace with no sandbox ref cannot still be provisioning anywhere, so the process that was provisioning it is gone. It is the only reaper of that state, and the process that could reach it may never boot again, so boot-only would strand it forever.
  2. sweepRunWatchers — at boot AND every watcherSweepInterval after. It claims runs whose watcher lease has gone stale, so a run orphaned by a pod that never comes back is adopted by ANY live replica instead of waiting for that pod's own boot, which may never happen. This is what the per-run watcher goroutine (an in-process, un-persisted thing) could never provide alone.
  3. sweepOrphanedBuilds — at boot AND every buildSweepInterval after, independent of s.cfg.Runner: reaps envbuild build containers left behind by a crashed/restarted process (see ImageBuildSweeper). The boot pass alone is NOT enough, because the sweep's own safety gate is an age gate (2*BuildTimeout, envbuild/reaper.go): under systemd Restart=always or a k8s pod restart the process is back within seconds, so at the one moment anything looked, the orphan was too young to reap — and its writable layer would then leak until the next boot, which may be weeks away.

Best-effort: errors are logged, never fatal. Adopted watchers run on the daemon base context so they outlive this call, and observe the AGENT via the persisted agent_exec_id rather than the container — an idle-container exec run whose agent already exited is finalized instead of stranded.

func (*Server) ReconcileWorkspaceEgressDecisions added in v0.6.0

func (s *Server) ReconcileWorkspaceEgressDecisions(ctx context.Context) (int, error)

ReconcileWorkspaceEgressDecisions re-applies decided `always` egress verdicts to the run's primary workspace (persistWorkspaceEgressDecision is not atomic with Decide). AddWorkspaceEgressDecision is an idempotent upsert that also clears the mirror list. Returns the count re-applied. It runs once at boot (cmd/wardynd). ponytail: boot-only heals on the next restart; a periodic tick would heal sooner on a laptop that rarely reboots — add one if that window proves too wide. The operator's newest action must win: per (workspace, host) only the latest DecidedAt is kept, and a decision older than Workspace.EgressEditedAt (stamped by the egress-list PUTs) is skipped — an undone `always` still reads APPROVED, so a per-approval "applied" marker could not tell. Decided rows are never deleted: cap this with an incremental scan if the table grows large.

func (*Server) RecordCredentialReauthExpired added in v0.7.6

func (s *Server) RecordCredentialReauthExpired(n int)

RecordCredentialReauthExpired counts credential_reauth rows the approval sweeper aged out. Exported for cmd/wardynd's sweeper goroutine, which is the ONLY place this transition happens: by the time a row expires the sidecar that held for it gave up hours ago, so no resolve will ever meet it and counting at a resolve would count nothing at all.

func (*Server) RedeemADOEntraAccess added in v0.7.10

func (s *Server) RedeemADOEntraAccess(ctx context.Context, cfg ADOEntraConfig, owner string, scopes []string) (ADOEntraAccess, error)

RedeemADOEntraAccess mints an Azure DevOps access token for owner, REQUESTING only the scopes named.

IT DOES NOT PROMISE A NARROW TOKEN, and no caller may act as though it does. Measured against a real tenant, Entra ignores the requested subset for the Azure DevOps resource: the token comes back carrying every scope the person consented to, whatever was asked for. The subset is still sent because that is what a correct client sends, it costs nothing, and it is what would narrow if this ever changes — but the truth a caller must build on is the returned ADOEntraAccess.Scopes, and the layer that actually bounds a run is Wardyn's own capability check in front of the resource, not the token.

`.default` is refused regardless: it asks for every permission the application holds rather than naming anything, so a request carrying one says nothing about what the caller believed it needed.

SINGLE-FLIGHT, and the read happens INSIDE the lock: a second caller that had already read the pre-rotation blob would otherwise redeem the spent token, take an invalid_grant, and classify a perfectly good credential as dead. Re-reading under the lock means the second caller redeems what the first one persisted.

A Put that fails AFTER a successful redeem does NOT fail the caller: the rotated token is already live at Entra and the presented one is already spent, so refusing would throw away a credential we hold. The caller is served from memory and the persist failure is logged — the next caller will find the stored token dead and say so honestly, which is strictly better than pretending this one failed.

func (*Server) ServeSSHGateway added in v0.5.0

func (s *Server) ServeSSHGateway(ctx context.Context) error

ServeSSHGateway blocks running the SSH gateway's accept loop until ctx is cancelled or the listener fails unrecoverably. A no-op (nil error) when the gateway is disabled — WARDYN_SSH_LISTEN empty = off = no listener, no new surface — so cmd/wardynd may call this unconditionally once it has decided whether to launch it in a goroutine at all.

func (*Server) SweepExpiredCredentials added in v0.8.0

func (s *Server) SweepExpiredCredentials(ctx context.Context) int

SweepExpiredCredentials deletes every stored credential whose expiry has passed, audits each as credential.expired.delete, and returns how many it deleted. cmd/wardynd calls it daily. A row it could not delete is kept, and the next sweep tries it again. A store that cannot sweep — no DeleteExpired, or a wrapper answering secretstore.ErrNoExpirySweep — is logged at Error once.

func (*Server) SweepRunSecrets added in v0.6.0

func (s *Server) SweepRunSecrets(ctx context.Context) int

SweepRunSecrets drops the per-run plaintext secret corpora held in memory for runs that went terminal more than RunSecretGrace ago, and reports how many it evicted.

Eligibility is read from the run's own state rather than hooked onto a terminal-transition call site, so every path that ends a run (watcher, kill, idle reaper, boot reconcile, failAndRevoke) is covered by construction and no new one can be forgotten. It fails CLOSED on anything it cannot positively prove terminal-and-cold: a store error, or a held run id with no row in the listing, keeps its secrets.

It also drops the process-wide mask copies of credentials that were refreshed or deleted more than RunSecretGrace ago (secretmask.Registry.SweepGlobals), on the same grace and for the same reason.

func (*Server) SweepTerminalSandboxes added in v0.5.0

func (s *Server) SweepTerminalSandboxes(ctx context.Context) (int, error)

SweepTerminalSandboxes is the retry surface for two gaps in the same family: a failed StopSandbox/RevokeRun step inside a prior finalize (finalizeRunTail) or kill (handleKillRun) leaves a terminal run row with a sandbox nothing else revisits — ReconcileOnBoot skips terminal runs outright (reconcile.go), and handleKillRun 409s a non-KILLED terminal run rather than risk corrupting its recorded outcome — and, separately, killTeardownTail itself can be interrupted (a shutdown past its grace, a crash) after the KILLED CAS has already landed but before the teardown/revoke/audit tail finishes, leaving a run correctly marked KILLED with no run.kill row at all (see recoverAbandonedKillTail).

This PROBES the runner for every terminal run with a SandboxRef (never trusts the row's own state — the row is terminal by definition, so only a live probe can tell orphaned from settled), tears down + re-runs the idempotent revoke cascade for anything still reported running, retries any KILLED row whose own kill tail looks abandoned, and reports how many it swept. Read-only on run STATE: it never transitions a run (a terminal row's recorded outcome is untouched either way), only the runner + broker/ identity + audit side effects finalizeRunTail/killTeardownTail already perform for every other terminal transition.

Caller: the operator route (POST /admin/sandboxes/sweep, unbounded — an operator asked for it once) and, bounded, the periodic ticker (cmd/wardynd's runTerminalSandboxSweeper, via SweepTerminalSandboxesPage below).

func (*Server) SweepTerminalSandboxesPage added in v0.8.0

func (s *Server) SweepTerminalSandboxesPage(ctx context.Context, page store.Page) (swept, pageLen int, err error)

SweepTerminalSandboxesPage is SweepTerminalSandboxes bounded to one page of runs, for the periodic ticker: unlike the operator-triggered SweepTerminalSandboxes, which is deliberately unbounded because a human asked for it once, a ticker's cost must never grow with run history. It pages through store.Pager.ListRunsPage (the same ordering ListRuns' unbounded read already uses, created_at DESC) — the caller advances page.Offset by pageLen each tick and wraps back to 0 once pageLen comes back short of page.Limit, so repeated ticks eventually cover the whole table at bounded per-tick cost. Falls back to sweeping every run in one pass when the store does not implement store.Pager (a fake without it, in tests) — real PG always does.

func (*Server) UIGatewayHandler added in v0.6.0

func (s *Server) UIGatewayHandler() http.Handler

UIGatewayHandler returns the gateway's http.Handler, or nil when the gateway is disabled (cmd/wardynd then starts no listener). It is deliberately NOT mounted on the console router: these routes must exist ONLY on the second origin, and Server.Handler() must 404 them — pinned by test.

Consequence, stated because it is easy to miss: being off the chi router also makes this surface invisible to TestAuthzMatrix, which discovers routes by walking that router. A green authz matrix says NOTHING about relay authorization. The ticket/session tests in uigateway_test.go are this surface's own pin, exactly as sshgateway_test.go is the SSH gateway's, and the matrix's own doctrine comment now names both boundaries.

func (*Server) WaitBackground added in v0.8.0

func (s *Server) WaitBackground()

WaitBackground blocks until every goroutine started through goBackground has returned, or until backgroundShutdownBudget elapses — whichever comes first. Bounded because this runs on the shutdown path with an operator waiting: a genuinely wedged teardown (a runner call that never returns) must not turn an orderly stop into a hang. A hit budget is logged rather than silent, so an abandoned kill cascade or launch is visible in the log instead of just disappearing.

type SetupAgeKey

type SetupAgeKey struct {
	Durable bool `json:"durable"`
}

SetupAgeKey reports whether the secret store survives a restart (a stable WARDYN_AGE_KEY was supplied vs an ephemeral generated one).

type SetupAuth

type SetupAuth struct {
	Mode          string `json:"mode"`
	LocalLoopback bool   `json:"local_loopback"`
	// SharedSubscriptionAllowed reports whether this deployment may inject ONE
	// operator's Anthropic subscription into runs (single-user desktop only; see
	// subscriptionInjectPosture in cmd/wardynd). The console reads it to decide
	// whether to offer the "Connect Claude subscription" affordance at all —
	// rendering a sign-in that cannot work is worse than not offering it.
	SharedSubscriptionAllowed bool `json:"shared_subscription_allowed"`
	// SharedSubscriptionReason says WHY it is unavailable, so the UI can explain
	// rather than looking identical to "the operator never logged in". Empty when
	// allowed.
	SharedSubscriptionReason string `json:"shared_subscription_reason,omitempty"`
}

SetupAuth is the active public-API auth mode: local (loopback bypass) | sso (OIDC) | token (admin bearer) | disabled (no auth configured, API closed).

type SetupBedrock

type SetupBedrock struct {
	Region string `json:"region,omitempty"`
	Model  string `json:"model,omitempty"`
	// The four credential SOURCES resolveBedrockAuth accepts, in its precedence
	// order (bearer > captured AWS SSO session > ~/.aws mount > resident SigV4).
	// ANY one is sufficient — a mount-, bearer- or SSO-credentialed host has NO
	// aws-access-key-id/-secret secrets yet is fully ready, so gating readiness on
	// CredsPresent alone wrongly reads "needs setup".
	CredsPresent  bool `json:"creds_present"`  // resident aws-access-key-id + aws-secret-access-key secrets
	AWSMount      bool `json:"aws_mount"`      // host-mode read-only ~/.aws bind-mount (SSO auto-refreshes)
	BearerPresent bool `json:"bearer_present"` // bedrock-api-key bearer token secret (never resident)
	// SSOPresent: a captured container-login AWS SSO session that a run would
	// actually authenticate with — live, OR expired-but-renewable (dispatch
	// renews it). Only a session nothing can heal (no refresh token, or a lapsed
	// client registration) reads false.
	SSOPresent bool `json:"sso_present"`
	// SSOExpired: a session IS captured and nothing can heal it (no refresh
	// token, or a lapsed registration). NOT on the wire, deliberately: it exists
	// so the capability matrix can say "the credential expired" instead of "no
	// credential is configured" — two different things an operator fixes two
	// different ways — and nothing on the console reads a second copy of a fact
	// SetupStatus.ModelAccess already carries per principal.
	SSOExpired bool `json:"-"`
	// SSOAccountID/SSORoleName are the AWS account and IAM role the CALLER's own
	// captured session names. IN-PROCESS only (json:"-"), for the same reason
	// SSOExpired is: nothing on the console renders a second copy of an identity
	// SetupStatus.ModelAccess already speaks for per principal. They exist so
	// bedrockProviderCheck can say, on the ADMIN's own Bedrock row, that a
	// stored capture disagrees with the roster pin — the posture that is
	// otherwise audible only as somebody else's refused run.
	SSOAccountID, SSORoleName string `json:"-"`
	// PerUser/Mechanism: the CALLER's own awsSSOScope, echoed in-process (never
	// on the wire — bedrockProviderCheck's callsite already has bedrock in
	// hand, so no second SetupStatus consumer needs to re-derive it) so
	// bedrockProviderCheck/llmProviderCheck can tell "a real person's own
	// credential is missing" from "the shared admin token was asked a
	// per-person question" without re-resolving scope themselves.
	PerUser bool `json:"-"`
	// Mechanism: this caller IS the shared admin bearer token under a per_user
	// row — see awsSSOScopeIsMechanism. Always false when PerUser is false.
	Mechanism bool `json:"-"`
	// PerUserBearer: the roster row's own MECHANISM (types.AgentMechanism) is
	// bedrock_bearer rather than bedrock_sso, under a per_user row. Meaningless
	// (always false) when PerUser is false. IN-PROCESS only (json:"-"), for the
	// same reason PerUser/Mechanism are: it exists so bedrockProviderRow can
	// tell an SSO caller from a bearer caller and pick the sentence that names
	// the ACTUAL remedy — signing in to AWS is not it for a bearer row; storing
	// a bedrock-api-key of their own is (#153, #320).
	PerUserBearer bool `json:"-"`
	// Ready is the server-computed readiness (region+model+any credential source),
	// echoed so the UI doesn't re-derive — and drift from — this gate.
	Ready bool `json:"ready"`
}

SetupBedrock is the Amazon Bedrock Anthropic-transport readiness snapshot the wizard renders. This file is the PROBE half of the Bedrock lane and runs_bedrock.go is the RESOLVE half, split only because the one file hit the size gate; they are still one seam, because ready() must accept exactly the credential set resolveBedrockAuth accepts: a drift between them (readiness omitting a credential lane resolveBedrockAuth accepts) would tell an operator whose runs authenticate fine that no integration could drive Claude Code. Keep the two lists edited together.

type SetupCheck

type SetupCheck struct {
	ID       string `json:"id"`
	Label    string `json:"label"`
	Status   string `json:"status"`
	Platform string `json:"platform,omitempty"`
	Detail   string `json:"detail,omitempty"`
	Fix      string `json:"fix,omitempty"`
	Blocking bool   `json:"blocking,omitempty"`
	// Cause narrows a row that can warn for more than one reason, the same
	// shape as SCMAccess.cause on the wire (ui/src/app/lib/types/setup.ts) — a
	// machine key, never prose, so a console reader can pick per-cause copy
	// without string-matching Detail. Only sso_rbac sets it today ("default_role",
	// #491): #484's original no-role-map-and-no-admin-list warn leaves it empty.
	Cause string `json:"cause,omitempty"`
}

SetupCheck is one environment/readiness row. Status is ok|warn|fail|info; "info" is a permanent, non-fixable condition (e.g. no /dev/kvm on macOS) that must render as informational, not as a clearable warning. Platform lets the UI show environment-appropriate copy (linux|darwin|windows|wsl|any).

Blocking decides ONE thing: whether the console must not open on this install at all — setupGateActive (the console's setup gate) redirects every route into the funnel while any row carries it, until onboarding completes. It is set on exactly three arms: runnerCheck's fail (no runner, so no run can happen), confinementFloorCheck's warn (a floor the runner cannot meet refuses every run before it launches), and ssoRBACCheck's warn (OIDC with no role mapping makes every signed-in human an admin, and the funnel's People step is where that is fixed).

It is NOT "this needs fixing" — nearly every warn/fail row does — and NOT a severity ranking. Two families must never carry it. Rows graded through the CALLER's own credential rather than the install: llmProviderCheck's per_user and provider arms, bedrockProviderCheck's per_user arm, providerAccessCheck and awsSSOCredentialRow (the caller's own AWS SSO session). Confiscating the console over a fact about one person is exactly what this flag exists to prevent. And advisory install rows, whose grade belongs on every surface that renders them but whose fix is nobody's emergency: the SCM safest-path ladder, an ephemeral age key, TLS cookie posture, an acknowledged egress canary. (harnessCredential- Check is install-wide, not per-person — its managed-subscription arm reads an unscoped blob — and is advisory for the second reason, not the first.)

type SetupDeployment

type SetupDeployment struct {
	HostLike bool `json:"host_like"`
}

SetupDeployment reports whether the wardynd process itself sees a resident Claude login — true in host mode (run-host.sh: wardynd runs as the operator, ~/.claude + the claude binary are on its own PATH/HOME), false in the compose path (distroless container blind to the host). HONEST framing like detectKVM: this is "does THIS process see a resident claude", not "is it literally run-host.sh" — a compose container with ~/.claude bind-mounted would also read host-like. The UI uses it to fork the getting-started guidance (laptop/local vs team/server) and to explain why the LLM-access check is or isn't green.

type SetupFix

type SetupFix struct {
	Action      string `json:"action"` // "add_secret" | "scan_workspace" | "none"
	SecretName  string `json:"secret_name,omitempty"`
	WorkspaceID string `json:"workspace_id,omitempty"` // ID, not source — api.scanWorkspace takes an id
}

SetupFix is the structured, actionable remedy a UI button drives directly — no prose-parsing. Action "none" means informational only: there is no button, because the only fix is the OPERATOR widening their own ceiling (e.g. a dropped egress domain).

v1 verifies PRESENCE only (declared-present, never "verified" in the UI copy) — a stored secret, an onboarded+scanned workspace, a surviving grant. Live credential verification (does the key actually authenticate?) is a FUTURE seam at the egress proxy (broker-verified calls), not here — this stays a fast, pure read of already-stored state, no live probe and no new injection surface.

type SetupHarness added in v0.2.0

type SetupHarness struct {
	Provider    string `json:"provider"`              // "anthropic" | "aws"
	Captured    bool   `json:"captured"`              // a token blob is stored
	CapturedAt  string `json:"captured_at,omitempty"` // RFC3339, when pasted
	Aging       bool   `json:"aging,omitempty"`       // captured longer ago than harnessTokenAging
	SourceRunID string `json:"source_run_id,omitempty"`
	// ExpiresAt/Expired carry a REAL, machine-readable expiry and are populated
	// only for providers whose credential exposes one (AWS SSO does; an Anthropic
	// setup-token does not, which is the whole reason Aging exists as a
	// conservative age heuristic). Empty here means "this provider can't tell you"
	// — never "it doesn't expire".
	ExpiresAt string `json:"expires_at,omitempty"`
	Expired   bool   `json:"expired,omitempty"`
	// Renewable: the stored credential can be renewed without a fresh
	// interactive login — it carries a refresh token AND its OIDC client
	// registration has not lapsed. The SAME predicate resolveBedrockAuth and
	// setupBedrock apply (awsSSOBlob.renewable), deliberately: a row that read
	// "renewable" off the refresh token alone would render "nothing to do" for a
	// credential whose registration is gone, which dispatch gives up on. Legacy
	// sso_start_url profiles carry no refresh token and must be re-logged-in.
	Renewable bool `json:"renewable,omitempty"`
}

SetupHarness is a Wardyn-managed subscription credential's readiness. Derived purely from the stored blob (presence + capture age) — PRESENCE only, honesty law: no green badge implies the token was live-verified. setup-token tokens live ~1yr with no machine-readable expiry, so Aging is a conservative age-based "reconnect soon" flag, never a hard expiry claim.

type SetupHarnessTool added in v0.5.0

type SetupHarnessTool struct {
	ID            string `json:"id"`
	Display       string `json:"display"`
	HasGateway    bool   `json:"has_gateway"`
	HasLogin      bool   `json:"has_login"`
	NoManagedAuth bool   `json:"no_managed_auth,omitempty"`
	// Enabled is the ADMIN'S answer for this row: may a run name this agent?
	// True for every row when no AgentProviders block exists (legacy open mode),
	// otherwise the row's own state — and false for a catalog agent the block
	// does not mention at all.
	//
	// NOT omitempty, and that is the whole point: false is the value that has to
	// reach the console, which renders a disabled row disabled with a reason
	// rather than hiding it. A roster that silently drops agents is how "Claude
	// Code is just gone" becomes a support ticket.
	Enabled bool `json:"enabled"`
	// Mechanism and CredentialSource are the row's declared model-access lane and
	// whose credential it uses, empty when no block exists or no row names this
	// agent. They are the MEMBER-SAFE half of the agent policy: a lane name and
	// "shared"/"per_user" carry no host, no secret name and never the AWS access
	// portal URL, which is why redactSetupStatusForUser keeps them — a member
	// deciding whether to sign in has to be able to see that their org captures
	// credentials per person.
	Mechanism        string `json:"mechanism,omitempty"`
	CredentialSource string `json:"credential_source,omitempty"`
	// CredentialResidency is published for exactly ONE row shape: an enabled
	// `per_user` + `bedrock_sso` row, which is "sandbox". EMPTY for every other
	// row, and that is the whole design.
	//
	// A roster cannot say where a credential lands — the RESOLVED lane decides
	// that, and resolving one here would mean dry-running a body New Run never
	// sends (it sends a policy_id or a minimal inline_policy, and create folds the
	// run/workspace/default integration first), so the answer could be confidently
	// wrong in either direction. The per-user Bedrock SSO row is the one case the
	// row settles by itself: it admits no other lane (resolveBedrockAuth stops at
	// the per-user branch, mechanismSatisfied accepts only bedrock_sso), and that
	// lane writes the captured session into the sandbox and mints resident SigV4
	// credentials from it whatever policy, workspace or integration the run
	// carries. It is also the field report's own estate, and the one state where
	// the precise answer is unavailable: Preflight 422s a member who has not
	// signed in.
	//
	// Every other row reads "Resolved at launch." in the console until the caller
	// presses Preflight, which answers for the exact run. Member-safe for the same
	// reason the two fields above are.
	CredentialResidency string `json:"credential_residency,omitempty"`
	// ProvidersUngranted (#1052) is true when at least one enabled model
	// provider serves this harness but this caller's own granted set
	// (setupModelProviderState's capVisible-narrowed model_providers) serves
	// none of them — canon.md's R5b, "no provider serves this person for this
	// harness at all". Distinct from R9 (nothing serves the harness at all,
	// which leaves this false): /setup/status's model_providers list is
	// already narrowed to granted providers before the wire, so without this
	// fact the two states were indistinguishable to the console.
	ProvidersUngranted bool `json:"providers_ungranted,omitempty"`
}

SetupHarnessTool is one coding-agent harness's static catalog metadata (harnessCatalog, harness.go): display name, plus whether Wardyn can wire it a managed model credential (Gateway) and/or a container-login subscription (Login), or whether it is the BYOA row (NoManagedAuth). This is the STATIC "what tools does Wardyn know how to run" catalog — distinct from SetupHarness, which reports a CAPTURED credential's live readiness.

type SetupIntegration added in v0.5.0

type SetupIntegration struct {
	Capabilities []Capability `json:"capabilities"`
	// contains filtered or unexported fields
}

SetupIntegration is one integration as the UI sees it: the effective row (stored or legacy-derived, integrationRow) plus its live capability matrix. Shared wire shape for GET /integrations and SetupStatus.Integrations, so there is exactly one representation of "an integration" on the wire.

func (*SetupIntegration) UnmarshalJSON added in v0.5.0

func (si *SetupIntegration) UnmarshalJSON(b []byte) error

UnmarshalJSON decodes the embedded row (see integrationRow.UnmarshalJSON — the embedded Integration's custom decoder would otherwise be promoted here and swallow capabilities) and then the wrapper's own field.

type SetupItem

type SetupItem struct {
	Kind       string    `json:"kind"` // "llm_access" | "secret" | "workspace" | "workspace_secret" | "repo_credential" | "egress" | "backend"
	ID         string    `json:"id"`   // stable "<kind>:<key>", e.g. "secret:anthropic-api-key"
	Label      string    `json:"label"`
	RequiredBy string    `json:"required_by"`
	Status     string    `json:"status"` // "satisfied" | "missing" | "unverified"
	Detail     string    `json:"detail,omitempty"`
	Fix        *SetupFix `json:"fix,omitempty"`
	// Residency names WHERE the credential this item concerns actually lives at
	// run time, derived from the FINAL spec's own delivery mechanism (never
	// guessed): "proxy_injected" (an api_key grant — the value never leaves the
	// wardyn-proxy sidecar, injection.go), "resident_mount" (a host credential
	// bind-mounted into the sandbox, e.g. the Claude subscription mount), or
	// "brokered_mint" (a github_token/git_pat grant — the broker mints/resolves
	// a value and hands it directly to the in-sandbox git-credential helper at
	// task time, internal.go handleInternalMint). Empty when not applicable
	// (workspace/egress/backend rows carry no single credential).
	Residency string `json:"residency,omitempty"`
}

SetupItem is a per-requirement readiness verdict for a composed run, computed DETERMINISTICALLY from the FINAL post-clamp spec (never LLM self-assessment) — the same trust rule composer.Grade uses (composer/risk.go). It generalizes the composeLLMAccess pattern (compose.go) to every setup requirement a proposal implies: secrets, onboarded workspaces, repo credentials, egress.

NOT SetupCheck (setup.go) — that type's Fix is free-text prose for a human to read; SetupItem's Fix is a structured action a UI button can drive directly (add_secret/scan_workspace), so it needs its own shape rather than reusing SetupCheck's.

type SetupModelAccess added in v0.7.2

type SetupModelAccess struct {
	State     string `json:"state"`
	Mechanism string `json:"mechanism,omitempty"`
	// Action is the one thing to do, already composed by the server ("" when
	// there is nothing).
	Action string `json:"action,omitempty"`
	// Deadline is the instant Action names — the registration's lapse, or the
	// access token's expiry for a blob that cannot be renewed. It serves the
	// admin's checklist row, which prints the same one the member's action line
	// does, and it is on the wire.
	//
	// Localisation is why: `expiring`
	// rides a strip on every screen for the whole 24-hour window, and the
	// sentence carries an RFC3339 UTC stamp, which a reader in another timezone
	// misreads every time they see it. The console re-composes that ONE line
	// through the same frozen template (AGENTS.MODEL_ACCESS_EXPIRING_ACTION) on
	// the reader's own clock, and relativeTime in the strip; it needs the
	// instant to do either.
	//
	// Safe for a member only because userModelAccess builds a FRESH struct
	// that drops it — the leak that function exists to close was this same
	// instant republished one field over. Pinned:
	// TestModelAccessDeadline_OnTheWireForItsOWNER_NeverForASharedMember.
	Deadline string `json:"deadline,omitempty"`
	// PinMismatch says this answer's `expired_signin` is a CONTRADICTED identity
	// rather than a lapsed session. IN-PROCESS only (json:"-"): the console
	// renders Action verbatim and needs no second copy of what it says — this
	// exists so the admin-facing checklist row (awsSSOCredentialRow) does not
	// tell an operator their live session "expired at <ts> and cannot be
	// renewed", which of this one credential is simply false.
	PinMismatch bool `json:"-"`
	// PerUser says whether this answer is about a credential this principal
	// owns. IN-PROCESS only (json:"-"): it is not a fact the console renders, it
	// is what userModelAccess needs to decide whether a member may be told a
	// deadline or offered a sign-in at all. False is `shared` AND legacy open
	// mode — in both, the graded blob is the OPERATOR's.
	PerUser bool `json:"-"`
	// Cause is WHICH fact put this answer into `expiring` — "" for every other
	// state. IN-PROCESS only (json:"-"): the six WIRE states are the vocabulary
	// the console renders and stay unchanged; Cause exists only so grading and
	// the admin checklist row (awsSSOCredentialRow) can tell apart two different
	// facts that happen to want the same door — a refresh token AWS has already
	// retired (causeRenewalSpent) is not the same story as a registration or an
	// unrenewable access token approaching ITS OWN lapse on schedule.
	Cause string `json:"-"`
}

SetupModelAccess is THIS PRINCIPAL's model-access answer: which state their own credential is in, the lane it is on, and the one thing to do about it.

It is the field the member's chip reads INSTEAD of llm_ready, which stays a deployment fact (does SOME model path exist on this install) and cannot answer a per-person question. Redaction-safe by construction: a state name, a wire mechanism value and a member-facing sentence — no secret names, no topology, no AWS access portal URL. redactSetupStatusForUser therefore KEEPS it.

Absent (a zero State) when there is nothing per-principal to say: no roster declares a lane for this agent AND no captured session exists. The console then renders today's chip, which is exactly what an install that never touched this feature should see.

type SetupModelProvider added in v0.8.0

type SetupModelProvider struct {
	ID   string `json:"id"`
	Name string `json:"name,omitempty"`
	Kind string `json:"kind"`
	// Disabled is published, never hidden: a turned-off default is why this
	// person's runs are refused, and they need to see it.
	Disabled bool `json:"disabled,omitempty"`
	// Harnesses are the ones this provider serves that this person may launch.
	Harnesses []string `json:"harnesses"`
	// DefaultFor are the harnesses whose roster default this provider is.
	DefaultFor []string `json:"default_for,omitempty"`
	Host       string   `json:"host"`
}

SetupModelProvider is one model provider as a person sees it on /setup/status: enough to choose it, and — D7 — the host their own credential would be sent to (the host only, never a path). No start URL, account pin, header scheme or UID. Built the same for admins and members, so there is nothing for the member redaction to strip.

type SetupPlatform

type SetupPlatform struct {
	OS  string `json:"os"`
	WSL bool   `json:"wsl"`
	// KVM: the host exposes /dev/kvm — lets the UI split Vault's "incompatible
	// with this hardware" from a fixable "needs setup" (additive; old UIs ignore).
	KVM bool `json:"kvm"`
}

SetupPlatform is the wardynd host's OS + WSL posture.

type SetupProvider

type SetupProvider struct {
	Tool             string `json:"tool"`
	Installed        bool   `json:"installed"`
	LoggedIn         bool   `json:"logged_in"`
	LoginDetectedVia string `json:"login_detected_via,omitempty"`
	// AuthMode is how the CLI authenticates, when detectable: "subscription" (a
	// resident Claude OAuth token is present — fresh OR expired; freshness lives in
	// the llm_provider check Detail, not here) or "" (unknown; never guessed). The
	// "api_key" value is reserved in the contract but not inferred for a CLI (no
	// cheap honest signal); codex stays "" (no auth-file parse).
	AuthMode string `json:"auth_mode,omitempty"`
}

SetupProvider is a resident coding-agent CLI (claude|codex) detected on PATH. LoggedIn is ADVISORY (a home-dir credential-file heuristic, not a live check).

type SetupProviderAccess added in v0.8.0

type SetupProviderAccess struct {
	Provider    string     `json:"provider"`
	State       string     `json:"state"`
	Action      string     `json:"action,omitempty"`
	Deadline    string     `json:"deadline,omitempty"`
	SourceRunID string     `json:"source_run_id,omitempty"`
	AddedAt     *time.Time `json:"added_at,omitempty"`
	LastUsedAt  *time.Time `json:"last_used_at,omitempty"`
}

SetupProviderAccess is one provider's connection state for the caller — the wire shape provider_access: [{provider, state, action, deadline}] the design names: a provider id, one of the five SetupModelAccess states, an already-composed sentence, and (only when the state names one) a deadline instant. No secret name, no start URL, no host. The one place a pinned account and role appear is the pin-mismatch action, which names both pairs to the caller — members included — because they must pick the pinned pair when they sign in again (the same sentence model_access already sends).

SourceRunID is the sign-in run whose capture the caller's stored credential for this provider is (#993): stamped server-side from that run's own token when the capture lands (ssotoken.go, provider_signin.go), read from the caller's own provider-scoped store, so the console confirms THIS sign-in from the credential itself rather than from its best-effort audit row. Absent for a kind with no sign-in (a typed key) and for no capture.

AddedAt and LastUsedAt are the caller's own stored credential's metadata (CS-6), absent when none is stored.

type SetupRunner

type SetupRunner struct {
	Driver                string            `json:"driver"`
	ConfinementClasses    []string          `json:"confinement_classes"`
	ConfinementSubstrates map[string]string `json:"confinement_substrates,omitempty"`
	// EphemeralDiskEnforcement is what actually binds a run's disk_mib on this
	// deployment — `filesystem`, `eviction` or `none`; absent reads as `none`
	// (runner.Capabilities.EphemeralDiskEnforcement, weakest across substrates).
	// The Workspace Providers screen renders it beside default_disk_mib so an
	// admin setting a number can see whether anything will hold it.
	//
	// Operator-only: redactSetupStatusForUser rebuilds this struct with
	// ConfinementClasses alone, so the word never reaches a member. It is
	// deliberately absent from the ANONYMOUS /healthz, which composes its own body
	// field by field.
	EphemeralDiskEnforcement types.StorageEnforcement `json:"ephemeral_disk_enforcement,omitempty"`
}

SetupRunner echoes the runner name and the live confinement classes/substrates.

type SetupSecrets

type SetupSecrets struct {
	Present   []string `json:"present"`
	GitHubApp bool     `json:"github_app"`
}

SetupSecrets reports present secret NAMES (reserved names excluded) and a convenience bool for whether both GitHub App secrets are set.

type SetupStatus

type SetupStatus struct {
	// Ready is server-computed and CONSERVATIVE (false when the runner is nil),
	// so the wizard opens rather than hiding a half-configured bootstrap.
	Ready bool `json:"ready"`
	// Checks is the single list of environment/readiness rows the UI renders.
	Checks []SetupCheck `json:"checks"`
	// Auth is the active public-API auth posture.
	Auth SetupAuth `json:"auth"`
	// Runner is the sandbox runner + the confinement classes actually live on
	// this host (from Runner.Capabilities, same source as /healthz).
	Runner SetupRunner `json:"runner"`
	// Providers reports resident coding-agent CLIs detected on the wardynd host.
	Providers []SetupProvider `json:"providers"`
	// Secrets reports which known secrets are present (NAMES only, reserved
	// names excluded) — never any value.
	Secrets SetupSecrets `json:"secrets"`
	// AgeKey reports whether the at-rest secret store survives a restart.
	AgeKey SetupAgeKey `json:"age_key"`
	// CredentialStorage names the KIND of store credentials live in — never a
	// host, path or vault name (design F-3): local/key_service/vault/key_vault.
	// Unlike Checks (the same fact as admin-only detail), this is KEPT through
	// redactSetupStatusForUser — every person reads it, not just an admin.
	CredentialStorage string `json:"credential_storage,omitempty"`
	// HasRuns drives the wizard's "launch your first run" done state.
	HasRuns bool `json:"has_runs"`
	// OnboardingComplete reports whether an operator has finished (or
	// deliberately left) the Getting Started funnel on this install —
	// SiteConfig.OnboardingCompletedAt, flattened to the only bit the console
	// needs. It is a fact about the install, not about the browser: browser
	// localStorage would outlive wiped databases and disagree with itself
	// between 127.0.0.1 and localhost (different origins, different storage).
	// The first-run landing, the welcome hero and the setup gate all read THIS.
	OnboardingComplete bool `json:"onboarding_complete"`
	// Platform is the OS + WSL posture the environment-step copy keys off.
	Platform SetupPlatform `json:"platform"`
	// HostProxy is the host-side proxy detection (env/shell/git/tool-config/OS)
	// the Host Proxy Getting-Started step renders. Read-only detection; it
	// never configures anything (the upstream-proxy plumbing is separate).
	HostProxy setup.HostProxyDetection `json:"host_proxy"`
	// SCM is the presence-only git-credential posture (gh CLI login, helper,
	// plaintext stores) the ScmProviderStep's ladder recommendations key off.
	SCM setup.SCMPosture `json:"scm"`
	// Bedrock is the AWS Bedrock Anthropic-transport readiness the "Connect a
	// model" step renders alongside the API-key/subscription rows. Region/Model
	// are boot-time operator config (non-secret, safe to echo); the AWS
	// credentials themselves are never echoed — CredsPresent is a bool derived
	// from secret-name presence, same as every other secret in this contract.
	Bedrock SetupBedrock `json:"bedrock"`
	// Deployment reports whether wardynd itself sees a resident Claude login
	// (host mode) or is blind to it (compose/container).
	Deployment SetupDeployment `json:"deployment"`
	// Harness reports per-provider Wardyn-managed subscription credentials
	// captured via container login (setup-token), so the wizard can show a
	// "connected / expiring / reconnect" row that works in compose mode where
	// there is no resident host login. Empty when no managed credential exists.
	Harness []SetupHarness `json:"harness,omitempty"`
	// Integrations is the effective integration set (stored ∪ legacy-derived,
	// see effectiveIntegrations in integrations.go) with each row's live
	// capability matrix — the same shape GET /api/v1/integrations returns,
	// folded in here so the wizard needs one fewer round trip. ADDITIVE field;
	// omitted when empty.
	Integrations []SetupIntegration `json:"integrations,omitempty"`
	// Harnesses is the STATIC coding-agent harness catalog (harnessCatalog,
	// harness.go) — which tools Wardyn knows how to run and whether it can
	// wire each one a managed model credential or a container-login
	// subscription. Distinct from Harness above (a CAPTURED credential's live
	// readiness). ADDITIVE field; omitted when empty.
	Harnesses []SetupHarnessTool `json:"harnesses,omitempty"`
	// LLMReady is the server-computed "does SOME run's LLM access path exist"
	// verdict (HIGH-4 review fix) — the same winning-signal logic that already
	// decides llmProvenance's detail (resident CLI login, a secret-name
	// heuristic, Bedrock, a managed harness token) OR'd with an AI-provider
	// Integration being configured. It exists
	// because a MEMBER'S redacted response (redactSetupStatusForUser) drops
	// the checks/providers/secret-name detail that would otherwise let the
	// console derive this itself — LLMReady is computed BEFORE redaction and
	// deliberately left untouched BY it, so the console's readiness chip / new-run
	// banner / demo gating keep working for a member without any of that detail
	// leaking. Kept in exact sync with ui/src/app/lib/types.ts's SetupStatus.
	LLMReady bool `json:"llm_ready"`
	// ChecksRedacted marks a body whose Checks/Providers/Secrets detail was
	// stripped for this caller's tier — an empty list here is withheld, not a fact.
	ChecksRedacted bool `json:"checks_redacted,omitempty"`
	// ModelAccess is THIS PRINCIPAL's model-access state — the per-person answer
	// LLMReady above structurally cannot give (it is a DEPLOYMENT fact, which is
	// why a member whose own AWS session had lapsed read a green chip off it).
	// Redaction-safe by construction and KEPT for a member: see SetupModelAccess.
	ModelAccess SetupModelAccess `json:"model_access,omitzero"`
	// SCMAccess is THIS PRINCIPAL's Azure DevOps access state — scmaccess.go's
	// computeSCMAccess, ModelAccess's sibling for #386. Zero value (state "")
	// when no Azure DevOps row is configured; safe for a member by
	// construction (their own state, computed from their own OIDC subject).
	SCMAccess SCMAccess `json:"scm_access,omitzero"`
	// TrustedCACerts is the number of additional roots WARDYN_TRUSTED_CA_FILE
	// loaded at boot (0 = unset). Derived from Config.TrustedCAPEM, never a
	// second boot-time field — see handleSetupStatus. Go + test only: no
	// console reader exists yet (the ui/src/app/lib/types.ts mirror is
	// hand-maintained, added when the Network step renders it) and
	// redactSetupStatusForUser does not zero it — a bare count carries no
	// PEM content, host name, or other detail members are barred from.
	TrustedCACerts int `json:"trusted_ca_certs,omitempty"`
	// ModelProviders is the model providers THIS PRINCIPAL may use, in the
	// member-safe shape (SetupModelProvider) — the same for every tier, so the
	// member redaction has nothing to strip. `omitzero`, not `omitempty`:
	// setupModelProviders returns nil when there is no provider block at all
	// (the field is then absent, exactly as before), and a non-nil, possibly
	// empty slice whenever a block exists — including one this caller is
	// granted nothing from, or one capVisible's own filter-error path
	// answered with rows[:0:0] — so the field is then present as `[]`. A
	// console reading this field can tell "no block" from "granted none"
	// only because of that distinction; `omitempty` could not make it (it
	// drops both nil and an empty-but-present slice alike).
	ModelProviders []SetupModelProvider `json:"model_providers,omitzero"`
	// ProviderAccess is THIS PRINCIPAL's connection state for every provider in
	// ModelProviders (MP-12) — one row per provider, generalising the single
	// AWS-SSO-only answer ModelAccess gives. Getting started and the setup
	// checklist read this instead of grading one hardcoded lane, so a person
	// granted several providers sees all of them. Kept for members: a state
	// name, an already-composed action sentence, and a deadline instant — no
	// secret names, no start URL. The pin-mismatch action is the one place the
	// pinned account and role appear (SetupProviderAccess's doc). Absent with
	// no provider block.
	ProviderAccess []SetupProviderAccess `json:"provider_access,omitempty"`
}

SetupStatus is the aggregate readiness snapshot for GET /api/v1/setup/status.

type UICookiePolicy added in v0.8.0

type UICookiePolicy struct {
	// Allow true forwards ONLY the cookies Names matches; false strips them.
	Allow bool
	// Names are exact cookie names, or prefixes when they end in "*".
	// Matching is case-sensitive, as browsers treat cookie names.
	Names []string
}

UICookiePolicy is WARDYN_UI_SANDBOX_STRIP_COOKIES parsed: which inbound cookies, beyond the wardyn_* namespace that is stripped unconditionally, the relay forwards to a sandbox app. The zero value is the default and forwards every other cookie.

It exists for a relay host under a registrable domain shared with other applications: a browser attaches a sibling host's Domain= cookies to the relay's requests, and the Cookie header carries only name=value, so a cookie's origin cannot be recovered — the operator names the cookies instead.

func ParseUICookiePolicy added in v0.8.0

func ParseUICookiePolicy(raw string) (UICookiePolicy, error)

ParseUICookiePolicy parses "allow:<names>" or "deny:<names>", a comma-separated list of cookie names or "prefix*" entries. Empty is the default policy. Boot refuses anything else (cmd/wardynd, validateUISandboxConfig), including an allow entry inside the wardyn_* namespace: those cookies are never forwarded, and a policy that reads as if it forwarded one is a misconfiguration to name, not to ignore.

Source Files

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL