Documentation
¶
Overview ¶
Governance profiles (migration 0052): the wire + store types for an ASSIGNABLE ceiling and the row that binds one to a subject.
Named `governance*` throughout, deliberately: "profile" already means two unrelated things in this tree — the Recording-Mode synthesis result and the workspace scan profile — and a third would make every grep ambiguous at exactly the surface where a mistaken read is a containment bug.
The rule for what a GROUP SUBJECT is, in the one package every write boundary that stores one can already reach.
A group subject is matched by exact string equality against the login-time group snapshot (store_capabilities.go's `subject = ANY($2::text[])`, and the same shape in ResolveGovernanceProfile). That snapshot is strictly narrower than "lowercase it": it carries printable ASCII only, guarded BEFORE the Unicode fold. So a subject outside that set is a row that can never match anyone — a DENY that protects nothing while the console renders it as active, and a group tier that HasGroupTierDriveGrants still counts as present.
WHY HERE. internal/auth/oidc holds the same rule for the MATCH surface, and the two capability/governance write boundaries call it there. The user-drive grant is validated in internal/types, and internal/types is imported by pkg/client and cmd/wardyn — so reaching for oidc's copy would drag the server's OIDC/JOSE dependency stack into the public client library and the CLI to canonicalize a string. internal/types is the leaf every one of those surfaces already depends on, which makes it the one place the rule can live once. oidc.CanonicalGroupSubject should become a delegate to this function; until it does, internal/api's TestCanonicalGroupSubjectHasOneAnswer asserts the two homes cannot answer differently.
Package types defines Wardyn's core domain vocabulary: the four nouns (AgentRun, RunPolicy, CredentialGrant, ApprovalRequest) plus the audit event shape. These types are the single source of truth shared by the control plane, runners, sidecars, and (on Kubernetes) the CRD layer.
User drives (migration 0054): the wire + store types for an admin-registered per-user storage allocation and the row that grants one to a subject.
Named `drive` throughout, and the noun/verb split is deliberate: a DRIVE is what an admin registers and allocates, MOUNT is what a run does with one. The member's run request carries only `{enabled, read_only}` and NEVER a path — the server derives the home name from the authenticated identity, so a member cannot name someone else's directory by typing it.
Two kinds fall out of the backend rather than being stored beside it:
- MANAGED — Wardyn allocates the object (a per-user Docker named volume, a per-user dynamic PVC).
- SHARE — the platform already has the tree (a host path the operator mounted from NFS/SMB, an admin-precreated PVC) and Wardyn binds one per-user subdirectory of it.
Everything here is a CLOSED enum validated at the write boundary, which is what lets migration 0054 carry three CHECKs pinned against these constants by internal/db's TestClosedEnumChecksMatchConstants.
THE HOME-SEGMENT RULES: what a single home name may look like, per substrate, and how each substrate's rule is said to an admin and to a member.
Split out of user_drive.go along the seam the file's own "─── derivation ───" divider already named. Everything here is about ONE string — the per-person directory segment — and about the two alphabets that string has to satisfy: a Docker volume-name component and a Kubernetes DNS-1123 subdomain. The derivation that PRODUCES that string, and the object naming that consumes it, stay next to the rows they read.
Index ¶
- Constants
- Variables
- func AIProviderKind(kind string) bool
- func ASCIIOnlySubject(s string) bool
- func CanonicalGroupSubject(s string) (string, bool)
- func CanonicalUserSubject(s string) string
- func ClosedADOTokenModeList() []string
- func ClosedAgentMechanismList() []string
- func ClosedCredentialSourceList() []string
- func ClosedGitLaneList() []string
- func ClosedGitProviderKindList() []string
- func ClosedIntegrationKindList() []string
- func DriveHomeName(d UserDrive, subject, override string) (string, error)
- func DriveHomeStricterRuleClause(b DriveBackend) string
- func DriveObjectName(d UserDrive, home string) string
- func DriveObjectNamedByWardyn(b DriveBackend) bool
- func DriveSlug(name string) string
- func DriveSubjectHash(subject string) string
- func FoldWorkspaceContract(attachments []WorkspaceAttachment, sources map[uuid.UUID]Source, ...) map[string]WorkspaceRequirement
- func ManagedBackendRejectsTemplate(backend DriveBackend, tmpl HomeTemplate) bool
- func ShareBackendRejectsTemplate(backend DriveBackend, tmpl HomeTemplate) bool
- func SplitRequirementKey(key string) (typ, rest string, ok bool)
- func ValidToolEffect(e ToolEffect) bool
- func ValidateUserDrive(d *UserDrive, runnerTarget string) error
- func ValidateUserDriveGrant(g *UserDriveGrant) error
- type ADOEntraConfig
- type ADOTokenMode
- type APIToken
- type ActorType
- type AgentMechanism
- type AgentProvider
- type AgentProviders
- type AgentRun
- type ApprovalDecision
- type ApprovalKind
- type ApprovalRequest
- type ApprovalScope
- type ApprovalState
- type ArtifactOverridedeprecated
- type AuditEvent
- type BaseImageEntry
- type CapabilityEffect
- type CapabilityGrant
- type CapabilitySubjectType
- type ConfinementClass
- type CredentialGrant
- type CredentialSource
- type DriveBackend
- type DriveKind
- type DriveMount
- type DriveReclaim
- type EgressRedirect
- type EphemeralProvider
- type FirstUseMode
- type GitLane
- type GitProvider
- type GitProviderKind
- type GovernanceAssignment
- type GovernanceLimits
- type GovernanceProfile
- type GrantKind
- type GrantSpec
- type HomeTemplate
- type Integration
- type IntegrationDelivery
- type IntegrationList
- type IntegrationSecret
- type InternalHost
- type LLMInspectionSpec
- type ResolvedDrive
- type ResolvedInjection
- type ResourceLimits
- type RoleMapping
- type RunLayout
- type RunLayoutWidget
- type RunPolicy
- type RunPolicySpec
- type RunState
- type SSHPublicKey
- type SiteConfig
- type Source
- type SourceKind
- type StorageEnforcement
- type StorageProviders
- type ToolEffect
- type ToolRule
- type UIApp
- type UserDrive
- type UserDriveGrant
- type UserDriveHostRootCheck
- type UserDriveListItem
- type UserDriveProvider
- type Workspace
- type WorkspaceAttachment
- type WorkspaceBaseImage
- type WorkspaceBedrockRef
- type WorkspaceKind
- type WorkspaceLLMCred
- type WorkspaceMount
- type WorkspaceProviders
- type WorkspaceRepo
- type WorkspaceRequirement
- type WorkspaceSource
- type WorkspaceSourceType
- type WorkspaceStatus
Constants ¶
const ( // MintConflictPending — the approval is still open; approval_id is set and // the caller may poll. MintConflictPending = "pending" // MintConflictDenied — a human denied the approval; denied/reason are set. MintConflictDenied = "denied" // MintConflictScopeMismatch — the requested scope widens the grant. MintConflictScopeMismatch = "scope_mismatch" // MintConflictAlreadyMinted — the grant's single-use slot is already spent. MintConflictAlreadyMinted = "already_minted" )
Mint 409-conflict "code" values (W19-W19a-2): the machine-readable discriminator between the four distinct fail-closed conditions that all share HTTP 409 on POST /wardyn/v1/credentials/mint, so the caller can name the real cause instead of guessing from which optional fields happen to be present.
THIS IS THE ONE HOME. The values were declared twice — internal/api's mint handler and cmd/wardyn-git-helper's client — and the helper's copy claimed to be "the ONE place both sides of this wire contract must agree", which it was not: the two blocks could drift silently, and the server-side tests compared the decoded JSON against the very constants the handler had written, so all four could be renamed with the suite green (F134). Both sides now read these, and internal/api's tests assert the LITERAL strings, which is what actually pins a wire contract.
internal/types is a leaf package (no internal imports), so the sandbox-side helper takes on no dependency by reading them here.
const ( IntegrationKindAnthropicAPIKey = "anthropic_api_key" IntegrationKindAnthropicSubscription = "anthropic_subscription" IntegrationKindBedrock = "bedrock" IntegrationKindOpenAIAPIKey = "openai_api_key" // IntegrationKindAzureOpenAI is GONE as of 0.5. Its single capability was // powering Wardyn's own features — the AI Run Composer — which no longer // exists; no agent tool can be pointed at an Azure OpenAI deployment (see // harness.go's reasonXAzureHarness). A stored row of the old kind now fails // closed at validateIntegrationWrite like any other unknown kind, and an // azure-openai-key secret is left untouched but inert. IntegrationKindGitHubApp = "github_app" IntegrationKindGitHost = "git_host" )
Integration kinds. An integration is a BASE COMPONENT extended by kind: a connection — secrets + egress — never an installer. The closed set below is every kind Wardyn has bespoke behavior for (provider transports, brokered clone lanes); ANY other kind is a GENERIC open slug (shape-validated), whose row carries its own secrets/egress/config — which is everything the runtime needs. UI grouping DERIVES from kind (AI set → "AI providers", github_app/ git_host → "Source control", else → "Connections"); there is no stored category.
const ( // DeliveryProxyHeader: the egress proxy presents the secret in an HTTP // header on requests bound for the integration's egress hosts. The sandbox // never holds it. The default, never-resident lane. DeliveryProxyHeader = "proxy_header" // DeliveryResidentFile: the secret is materialized as a file inside the // sandbox — a disclosed exception to never-resident. DeliveryResidentFile = "resident_file" // DeliveryResidentEnv: the secret is exported as an environment variable // inside the sandbox — a disclosed exception to never-resident. DeliveryResidentEnv = "resident_env" )
Integration delivery modes: how one secret reaches the run.
const ( OverrideOff = "off" // this workspace refuses the requirement outright OverrideOptional = "optional" // re-lane: off by default, a run may enable it OverrideRequired = "required" // re-lane: rides every run )
AttachmentOverride values — a workspace's per-attachment stance on ONE requirement key its attached source declares.
const ADOEntraAccessTokenSecret = "azure-devops-entra-access-token"
ADOEntraAccessTokenSecret is a SENTINEL secret name (NOT a stored secret), the fourth of them, and it resolves to an Azure DevOps access token minted from the RUN OWNER's own captured Entra sign-in (internal/api's resolveADOInjection). Nothing is stored under this name: the credential in the store is the person's refresh token, held in the reserved `wardyn-harness-ado-<row>-oauth` blob, and the access token exists only for the moment it is handed to the run's proxy sidecar.
THE TOKEN DOES NOT BOUND THE RUN, and no reader of this name may assume it does. Measured against a real tenant, Entra issues an Azure DevOps token carrying EVERY scope the person consented to whatever subset is requested, so what holds a run to its granted capabilities is Wardyn's own capability check in front of the resource — the proxy — not the credential. The granted scope string the authority reports is recorded on the audit row because a token is opaque and that string is the only honest evidence of what the credential can do.
Deliberately NOT in sinkReservedSecret, for AWSSSOAccessTokenSecret's reason: being resolved at that sink, host-pinned, is the whole point.
const AWSSSOAccessTokenSecret = "aws-sso-access-token"
AWSSSOAccessTokenSecret is a SENTINEL secret name (NOT a stored secret) that works exactly like the two OAuth sentinels above at the injection sink (host-pinned, forced header, masked), and resolves to the run's OWN captured AWS IAM Identity Center access token -- the credential an `aws sso login` captured into wardynd's secret store as a whole blob, not as a value under this name.
It is what makes Phase B (0.7.6) possible: portal.sso.<region> GetRoleCredentials is `authtype:none`, so the proxy can carry the session as the x-amz-sso_bearer_token HEADER and the sandbox never holds the token. The resolver is internal/api's resolveAWSSSOInjection, which additionally re-derives the credential's scope from the roster and refuses any drift from the snapshot the grant was authored with.
Deliberately NOT in sinkReservedSecret. That guard refuses names an api_key grant must never resolve; this name's ENTIRE purpose is to be resolved through that sink, host-pinned, exactly as bedrock-api-key is (see internal/api/secrets.go, which explains why that one is excluded too). And nothing is stored under it: a secrets-API Put of this name would be a value the sentinel arm never reads.
const AgentProviderTypeBedrock = "bedrock"
AgentProviderTypeBedrock is the COARSE provider type the four bedrock_* values fold to — the vocabulary of harnessDef.ProviderTypes and the Settings cards.
const IntegrationCredentialToken = "token"
IntegrationCredentialToken is the secret ROLE a generic header-delivered credential uses — the proxy-injected lane every header-authenticating system shares. The closed kinds keep their own role names, which encode a lane the runtime treats differently ("api_key", "pat", "ssh_key").
const ManagedOAuthSecret = "anthropic-managed-oauth"
ManagedOAuthSecret is a SENTINEL secret name (NOT a stored secret) that works exactly like SubscriptionOAuthSecret at the injection sink (host-pinned to api.anthropic.com, forced Authorization: Bearer, value masked), but resolves to the Wardyn-MANAGED subscription token: a long-lived `claude setup-token` OAuth token the operator captured via the container-login flow and Wardyn persisted (see internal/api/harnesscred.go). This is what lets a COMPOSE/ containerized deployment — whose distroless wardynd has no host ~/.claude to read — credential a subscription run proxy-side without ever making the token resident in the sandbox. Distinct from SubscriptionOAuthSecret only in its SOURCE (managed store vs resident host ~/.claude), so the audit trail names which one credentialed a run.
const SubscriptionOAuthSecret = "anthropic-subscription-oauth"
SubscriptionOAuthSecret is a SENTINEL secret name (NOT a stored secret). An api_key injection grant carrying it resolves at inject time to the operator's LIVE Anthropic subscription OAuth token (from the resident ~/.claude), not a value in the secret store — so subscription runs are credentialed proxy-side like api-key runs, the sandbox holding only the inert sentinel. It also serves as the durable "this profile uses subscription LLM auth" marker on a recorded profile (the resident ~/.claude mount that would otherwise signal subscription is never synthesized). Shared here so api + recordmode + UI agree on the name.
Variables ¶
var ( // ErrApprovalAlreadyDecided: a decision was attempted on an approval that // has already left PENDING. Fail closed — never let a second decision // silently overwrite the first. ErrApprovalAlreadyDecided = errors.New("approval already decided") // ErrDuplicatePendingApproval: a partial unique index rejected a second open // PENDING approval for the same dedup key, i.e. a concurrent raise lost the // race. It is a dedup signal (re-read the winner), NOT a hard failure. ErrDuplicatePendingApproval = errors.New("duplicate pending approval") )
The approval sentinels live here, in the one package both internal/store and internal/approval already import, so the FSM can errors.Is a store error instead of matching its message text (which it used to do, silently breaking the moment either message was reworded or wrapped). store.ErrAlreadyDecided / approval.ErrAlreadyDecided and store.ErrDuplicatePending are aliases of these.
var ApprovalKinds = []ApprovalKind{ ApprovalCredential, ApprovalEgressDomain, ApprovalToolCall, ApprovalCredentialReauth, }
ApprovalKinds is the closed set. It exists for the same reason ApprovalStates does, and it was missing for longer: internal/db's closedEnumChecks covered approvals.state and approvals.decision_scope but NOT approvals.kind, so a Go constant the database CHECK rejects would have left every gate green. TestApprovalKindsCoversEveryConstant keeps it honest against the constants above.
var ApprovalStates = []ApprovalState{ ApprovalPending, ApprovalApproved, ApprovalDenied, ApprovalExpired, ApprovalCancelled, }
ApprovalStates is the closed set, in lifecycle order (the one non-terminal state first, then the four terminal ones). It exists so the migration parity guard (internal/db's closedEnumChecks) compares the database's CHECK against the GO SET rather than against a hand-typed list in the test — the blindness the role_mappings.role case argues must be closed by derivation, and the one CHANGELOG 0062 advertises as closed. TestApprovalStatesCoversEveryConstant keeps this slice honest against the constants above.
var ClosedADOTokenModes = map[ADOTokenMode]bool{ ADOTokenModeBearer: true, }
ClosedADOTokenModes is the closed token-mode set — see ClosedGitLanes.
var ClosedAgentMechanisms = map[AgentMechanism]bool{ AgentMechanismAnthropicSubscription: true, AgentMechanismAnthropicAPIKey: true, AgentMechanismOpenAIAPIKey: true, AgentMechanismBedrockBearer: true, AgentMechanismBedrockSSO: true, AgentMechanismBedrockEnv: true, AgentMechanismBedrockAWSDir: true, AgentMechanismNone: true, }
ClosedAgentMechanisms is the closed mechanism set — the only values a write may name (validateAgentProviders refuses anything else), in the ClosedGitProviderKinds shape.
var ClosedCredentialSources = map[CredentialSource]bool{ CredentialSourceShared: true, CredentialSourcePerUser: true, }
ClosedCredentialSources is the closed source set — see ClosedAgentMechanisms.
var ClosedGitLanes = map[GitLane]bool{ GitLaneApp: true, GitLanePAT: true, GitLaneSSH: true, GitLaneEntra: true, }
ClosedGitLanes is the closed lane set — see ClosedGitProviderKinds.
var ClosedGitProviderKinds = map[GitProviderKind]bool{ GitProviderGitHub: true, GitProviderAzureDevOps: true, }
ClosedGitProviderKinds is the closed kind set — the only kinds a write may name (validateWorkspaceProviders refuses anything else), in the ClosedIntegrationKinds shape.
var ClosedIntegrationKinds = map[string]bool{ IntegrationKindAnthropicAPIKey: true, IntegrationKindAnthropicSubscription: true, IntegrationKindBedrock: true, IntegrationKindOpenAIAPIKey: true, IntegrationKindGitHubApp: true, IntegrationKindGitHost: true, }
ClosedIntegrationKinds is the closed kind set — the kinds with bespoke behavior in code, and as of 0.5 the ONLY kinds a write may name. A write naming one is validated against that kind's contract (config keys, DefaultFor eligibility); anything else is refused (validateIntegrationWrite).
var ConfinementClassNames = map[ConfinementClass]string{ CC1: "Fence", CC2: "Wall", CC3: "Vault", }
ConfinementClassNames maps each wire code to the friendly display label the UI shows instead (ui/src/app/components/wardyn/cc-meta.ts's CC_META labels) — the Go-side source of truth for that mapping, so the CLI's --confinement alias parsing and the /healthz payload can't drift apart.
var DriveBackends = []DriveBackend{ DriveBackendDockerVolume, DriveBackendHostPath, DriveBackendK8sPVC, DriveBackendK8sPVCStatic, }
DriveBackends is the closed set in the order the admin surface shows them.
var DriveReclaims = []DriveReclaim{DriveReclaimRetain, DriveReclaimDelete}
DriveReclaims is the closed set in admin-surface order.
var HomeTemplates = []HomeTemplate{HomeTemplateHash, HomeTemplateSub, HomeTemplateEmailLocal}
HomeTemplates is the closed set in admin-surface order.
var LegacyGitLanes = []GitLane{GitLaneApp, GitLanePAT, GitLaneSSH}
LegacyGitLanes are the lanes an EMPTY GitProvider.Lanes list admits: the three that existed when "empty means every lane the kind supports" was written down.
IT IS NOT "every lane in ClosedGitLanes", and that distinction is the whole reason this list exists. Admission expands an empty Lanes list to every lane, so growing the closed set alone would have opted EVERY STORED ROW into the new lane the moment it was added — an opening nobody authored, delivered to every install by the same MDM channel that delivers site-config. A lane added after this line must be NAMED on a row to be usable, so this list is frozen and never grows again.
var NonTerminalRunStates = []RunState{RunPending, RunStarting, RunRunning, RunWaiting}
NonTerminalRunStates is IsTerminal's complement as a LIST, for the one caller that needs the set as data rather than as a predicate: the concurrent-run quota's SQL (store.CountActiveRunsBy).
POSITIVE, never `NOT IN (terminal)`, and that direction is the safety. A state added to the enum and forgotten here undercounts by one state — the quota is merely looser than intended. Written as a negation, the same oversight would count a newly-added TERMINAL state as active and wedge every capped member at their limit forever, with no run they could stop to clear it.
TestNonTerminalRunStatesPartitionTheEnum pins this against IsTerminal over every constant scanned from this file, so the two cannot disagree.
Functions ¶
func AIProviderKind ¶ added in v0.5.0
AIProviderKind reports whether kind is one of the five AI provider flavors — the set eligible for DefaultFor marks and the run-time model-credential fold (applyIntegrationCreds), and the "AI providers" UI group.
func ASCIIOnlySubject ¶ added in v0.7.0
ASCIIOnlySubject reports whether s contains no rune above ASCII. It is the same rule oidc.ASCIIOnly states for the login-time match surface, spelled here because internal/types is imported by pkg/client and cmd/wardyn and must not drag the server's OIDC dependency stack in to answer a question about a string. internal/api's TestCanonicalGroupSubjectHasOneAnswer already pins the sibling pair against drift; the same idea covers this one.
func CanonicalGroupSubject ¶ added in v0.7.0
CanonicalGroupSubject canonicalizes an operator-authored group name into the EXACT string a session snapshot carries for a claim of that name, or reports ok=false when NO snapshot can ever carry it (empty/whitespace-only, or any character outside printable ASCII).
THE ASCII GUARD RUNS ON THE RAW VALUE, BEFORE THE FOLD, and the order is the security property. strings.ToLower does Unicode case mapping: KELVIN SIGN U+212A folds to ASCII 'k' and U+0130 folds to 'i', so guarding the LOWERED value would let a crafted subject "Kubernetes-admins" (U+212A) fold ONTO the operator-authored ASCII group "kubernetes-admins" and bind to it — inheriting every allocation the real group holds. Fold first, guard second, and the guard is decorative.
func CanonicalUserSubject ¶ added in v0.7.0
CanonicalUserSubject canonicalizes an operator-authored USER subject — an OIDC `sub` or an email address — into the form a write boundary stores.
SAME ORDER, DIFFERENT ANSWER FOR NON-ASCII. Like CanonicalGroupSubject it looks at the RAW value before folding, and for the same reason: strings.ToLower does Unicode case mapping, so KELVIN SIGN U+212A folds to ASCII 'k' and U+0130 folds to 'i'. An admin who types a look-alike spelling of a real human's address would otherwise have it FOLDED ONTO that human's subject and the row stored against them — a drive allocated to somebody nobody named, with an audit trail showing the exotic string the admin actually typed. Guard first, fold second, and there is nothing to fold onto.
It does NOT refuse a non-ASCII value, which is the one place it parts company with the group rule, deliberately. A group subject is matched by exact equality against the login-time snapshot, which carries printable ASCII only, so a non-ASCII group can never match anyone and saying so at the boundary is the honest answer. A USER subject is an identity, not an operator-authored label: the `sub` claim is whatever the identity provider issues, and refusing one because it is not ASCII would refuse a real person. So it is kept exactly as typed — unfolded, because folding is precisely what makes a look-alike dangerous, and unrefused, because it may be somebody.
The residual, stated rather than implied: internal/api's capabilitySubjects folds the session's own subject with a bare strings.ToLower, so a non-ASCII subject stored verbatim here is matched against a folded one there and the row matches nobody. It is a dead row, not a misdirected one — the direction that fails closed — and it is unreachable through the email lane, which oidc's deriveEmail already ASCII-guards at login. Closing it belongs on the session surface, where the fold is.
func ClosedADOTokenModeList ¶ added in v0.7.10
func ClosedADOTokenModeList() []string
ClosedADOTokenModeList is ClosedADOTokenModes in a stable order, for a rejected write's "want one of: …".
func ClosedAgentMechanismList ¶ added in v0.7.2
func ClosedAgentMechanismList() []string
ClosedAgentMechanismList is ClosedAgentMechanisms in a stable order, for the "want one of: …" half of a rejected write's error.
func ClosedCredentialSourceList ¶ added in v0.7.2
func ClosedCredentialSourceList() []string
ClosedCredentialSourceList is ClosedCredentialSources in a stable order, for a rejected write's "want one of: …".
func ClosedGitLaneList ¶ added in v0.7.2
func ClosedGitLaneList() []string
ClosedGitLaneList is ClosedGitLanes in a stable order, for a rejected write's "want one of: …".
func ClosedGitProviderKindList ¶ added in v0.7.2
func ClosedGitProviderKindList() []string
ClosedGitProviderKindList is ClosedGitProviderKinds in a stable order, for the "want one of: …" half of a rejected write's error. Sorted so the message is deterministic across map iterations.
func ClosedIntegrationKindList ¶ added in v0.5.0
func ClosedIntegrationKindList() []string
ClosedIntegrationKindList is ClosedIntegrationKinds in a stable order, for the "want one of: …" half of a rejected write's error. Sorted so the message is deterministic across map iterations.
func DriveHomeName ¶ added in v0.7.0
DriveHomeName derives the per-user home segment for one principal.
PRECEDENCE, and the first rule is the whole point of the override existing:
- override (a user-tier grant's home_override) WINS. An admin who has written down "Bob's directory on the NAS is bsmith" is stating a fact about a filesystem Wardyn does not own, and no template may out-vote it.
- hash — `d-` + the first 20 hex of sha256(drive id + "\n" + subject). The drive id is in the digest so one member's two drives never collide, and the "\n" separator keeps a concatenation from being ambiguous.
- sub / email_local — the CLAIM, lowercased, and email_local truncated at the first "@".
EVERY non-hash path is then checked against driveHomeSegmentRe and an unusable claim is an ERROR, NEVER A GUESS. Guessing here is not a cosmetic failure: a fabricated segment either collides with another member's home (two people sharing a directory neither was granted) or escapes it. The remedy the error points at is a home_override on that member's grant.
`subject` is the identity the home is derived FROM, and the caller selects it because only the caller holds the labelled claims: the caller's stable primary subject for `hash` and `sub`, the email claim for `email_local`. It is never the GRANT's subject — a group grant's subject would give an entire group one home, and re-pointing a grant would move a member's data.
func DriveHomeStricterRuleClause ¶ added in v0.7.0
func DriveHomeStricterRuleClause(b DriveBackend) string
DriveHomeStricterRuleClause is the same difference said to a MEMBER: the extra sentence a backend's home rule needs beyond the frozen refusal, or "" when the frozen sentence is already the whole rule.
The frozen sentence (DRIVE_MEMBER.REFUSED_HOME_INVALID) describes driveHomeSegmentRe — "lowercase letters and digits, then . _ -, up to 63 characters" — which is the DOCKER rule. On a Kubernetes backend driveHomeSegmentK8sRe is strictly narrower, and the gap is exactly the motivating case: an Entra `sub` is base64url and routinely carries `_`, so the member whose k8s drive is templated on `sub` was told the character that refused them was allowed, and asked their admin for nothing.
A SERVER-COMPOSED SUFFIX rather than a reworded canon (§7.1's "composed by the server — rendered verbatim, never keyed" class): the frozen table is one sentence per door and it is frozen, so the substrate's own extra clause is appended AFTER it. The canon sentence still ships byte-for-byte on every deployment, and a k8s deployment adds the clause its regex actually enforces.
It lives HERE, beside the two regexes, because it is prose about them: a copy in the API layer would be a third statement of a rule that already has two.
func DriveObjectName ¶ added in v0.7.0
DriveObjectName is what the runner asks the substrate for, given a home already derived by DriveHomeName:
- docker_volume -> wardyn-drive-<drive-slug>-<home>
- k8s_pvc[_static] -> wardyn-drive-<drive-slug>-<home>
- host_path -> <host_root>/<home>, cleaned
EVERY name Wardyn MINTS carries the DRIVE's slug, because the home alone does not identify a drive. It reads as though it did — a `hash` home folds the drive id, so one member's two drives are already two homes — but a home_override does not: the admin writes "Bob's directory is bsmith" on the GRANT, and the grant is the row that gets re-pointed from one drive to another (user_drive_grants is UNIQUE on (subject_type, subject), so re-pointing IS how a member moves between drives). With no slug both sides of that re-point named one volume, so Bob mounted the old drive's contents under the new drive's name, size and writable posture, and an offboarding command naming `wardyn-drive-bsmith` could not say which drive it was reclaiming.
The volume arm carried no slug on the reasoning that Docker volume names are global to a daemon holding one drive's worth of state — an assumption nothing enforces: user_drives has no cardinality constraint and a deployment may register any number of docker_volume drives. The slug costs a longer name and buys the same (drive, home) scoping the PVC arm always had, which is also what lets an operator reading `docker volume ls` tell two drives apart, as `kubectl get pvc` already could.
A SHARE keeps its own shape: <host_root> already scopes it, and the directory under it was named by whoever owns the tree, not by Wardyn — a slug there would name a directory that does not exist.
func DriveObjectNamedByWardyn ¶ added in v0.7.0
func DriveObjectNamedByWardyn(b DriveBackend) bool
DriveObjectNamedByWardyn reports whether WARDYN MINTS this backend's storage object name — the `wardyn-drive-<drive-slug>-<home>` arm of DriveObjectName — rather than joining a path somebody else already created.
IT IS NOT THE MANAGED/SHARE SPLIT, and conflating the two is what the home-template rules got wrong. DriveKind answers "does Wardyn CREATE the object?"; this answers "does Wardyn NAME it?". k8s_pvc_static is a SHARE by the first question — an admin provisions the claim and wardynd only ever Gets it — and a Wardyn-named object by the second, because a claim is addressed by name in a namespace and the name Wardyn asks for is the one it mints. Only host_path is on the other side: its object is `<host_root>/<home>`, a directory the share owner made and named.
It is the SAME expression DriveObjectName branches on, called rather than restated, so the rules keyed on it cannot drift from the name they are about.
func DriveSlug ¶ added in v0.7.0
DriveSlug folds a drive's human name into the DNS-1123 fragment a PVC name carries. ValidateUserDrive refuses a name that folds to nothing, so this never returns "" for a stored drive.
EXPORTED BECAUSE THE FOLD IS NOT INJECTIVE AND SOMETHING HAS TO ENFORCE THAT. It is a fold on purpose — "Corp NAS" and " Corp NAS! " are one slug, so a cosmetic rename does not re-home anybody (driveIdentityFields compares through it for exactly that reason). Across two ROWS the same property is a collision: UNIQUE(name) admits "Corp NAS" and "corp nas" as different names, and both mint wardyn-drive-corp-nas-bsmith for one home, so two drives hand one object to two sets of members with different sizes, writability and reclaim policy — caught today only at mount time, as a run failure, by the runners' wardyn.drive label check. The store writes this value into user_drives.name_slug and migration 0061 makes it UNIQUE for every backend whose object name Wardyn mints, which is the namespace the collision is actually in. The Go definition is the one that decides: the column holds what this function returned.
func DriveSubjectHash ¶ added in v0.7.0
DriveSubjectHash fingerprints the principal a drive object was allocated to, in the one shape a substrate LABEL may carry it: the first 20 hex of sha256(subject), lowercased and trimmed exactly as DriveHomeName folds it.
NON-PII IS THE WHOLE REQUIREMENT. A label is echoed verbatim by `docker volume inspect` and `kubectl describe` to anybody who can reach the daemon or the namespace, so the sign-in subject itself — or an address — must not be written there. A digest answers the only question the driver asks ("is the object I found the object THIS principal was allocated?") and answers no other.
It reuses driveHomeHashLen deliberately: the same 80 bits, collision-free for any plausible member count, and one number to reason about rather than two. The drive id is NOT in this digest, unlike DriveHomeName's — the label lives beside `wardyn.drive`, which already carries the id, and folding it in would make one principal's fingerprint differ per drive for no reader's benefit.
An EMPTY subject returns "" rather than the digest of "": the caller then writes no label at all, which is the label-less state the restore path already tolerates. Hashing the empty string would mint a fingerprint every identity-less caller shares — the one value that could make two principals look like one.
func FoldWorkspaceContract ¶ added in v0.5.0
func FoldWorkspaceContract( attachments []WorkspaceAttachment, sources map[uuid.UUID]Source, overlay map[string]WorkspaceRequirement, ) map[string]WorkspaceRequirement
FoldWorkspaceContract computes a workspace's EFFECTIVE requirements — the map applyWorkspaceRequirements consumes unchanged — from its attachments, the attached sources' own contracts, and the workspace's overlay rows.
Precedence, evaluated in order:
- Each attachment in order contributes its source's contract. A dangling SourceID (source deleted out from under it) contributes nothing — the mount gate is where that failure surfaces loudly, not here.
- An override of "off" drops that source's contribution for that key.
- An override lane (optional|required) replaces the source's lane for that contribution.
- SECURITY: a write:<path> key whose path is not the source's own Locator is DROPPED. applyWriteNarrowing widens ANY mount whose source path matches, so without this a shared source could declare write on a path it doesn't own and silently widen a sibling mount in every workspace that attaches it. A source may only claim write on itself.
- Collisions across attachments merge: Level = strongest (required beats optional); Provenance = WEAKEST (scan_seeded beats operator_set). Fail-closed on purpose: the run-create trust boundary auto-mints only operator_set secrets, so if ANY contributor's row came from reading untrusted repo content, the merged row must not auto-grant. A source's own operator_set row alone still auto-grants — an operator editing a shared source's contract is a direct operator act. ponytail: fail-closed on provenance collision; the workspace overlay is the one-row override if this is ever too strict.
- An overlay row REPLACES the merged value outright (level AND provenance). The overlay is what this workspace additionally declares or restates; overrides are how it refuses or re-lanes a source's row. The overlay deliberately cannot REMOVE a key — "off" is for that.
Pure: no store, no clock. With no attachments (or all-ephemeral), the result is exactly the overlay — the identity that makes migration 0031 provably behavior-identical for every pre-split workspace.
func ManagedBackendRejectsTemplate ¶ added in v0.7.0
func ManagedBackendRejectsTemplate(backend DriveBackend, tmpl HomeTemplate) bool
ManagedBackendRejectsTemplate is THE managed-backend home-template rule: a managed object's name ENDS in the home segment (DriveObjectName -> `wardyn-drive-<drive-slug>-<home>`), and it is printed by `docker volume ls` and `kubectl get pvc` without the inspect or describe a label needs — so on a managed backend only `hash`, which is unique and reveals nothing, may name one. A share backend keeps every template.
It is a function, and exported, because the rule has TWO enforcement points and they had drifted apart. The write boundary (Validate, above) was widened from `email_local` to every non-hash template; the RUN-TIME resolver (newResolvedDrive) was not, so a legacy or hand-written `sub` row was refused at authoring and still mounted — putting the sign-in subject in the object name, which is the exposure the widening exists to prevent. One predicate, so a third site cannot diverge again.
func ShareBackendRejectsTemplate ¶ added in v0.7.0
func ShareBackendRejectsTemplate(backend DriveBackend, tmpl HomeTemplate) bool
ShareBackendRejectsTemplate is the MIRROR rule, and it is exported for the same reason its twin above is: it had exactly one enforcement point.
A SHARE's directories are named by whoever owns the share, and Wardyn never mkdir's on one — so a `hash` home names a directory that does not exist and nothing will create it. The write boundary refused that row; the resolver did not, so a row written before the rule (or by hand) resolved cleanly and the member met the MISSING-HOME refusal instead — "directory d-00e23f375d35be941331 does not exist on the share — ask an admin to create it", which names a directory nobody could ever have made and asks the admin to create a digest. The remedy the member was handed was the wrong one for the row they actually had.
The empty template is excluded on both sides because ValidateUserDrive defaults it before this is asked, and a resolver looking at a legacy row with no template must fall through to the derivation rather than refuse a shape this rule has no opinion about.
func SplitRequirementKey ¶ added in v0.5.0
SplitRequirementKey splits a "<type>:<key>" requirement key on the FIRST colon only (a write:<path> key may itself legally contain colons) and reports whether the type token is known. This is the grammar's own parser, living beside the grammar; internal/api aliases it for its call sites.
func ValidToolEffect ¶ added in v0.7.0
func ValidToolEffect(e ToolEffect) bool
ValidToolEffect reports whether e is one of the three effects. A closed enum, checked at every ingest point: an unrecognised effect must be a 400, never a silently-ignored rule that reads as enforcement.
func ValidateUserDrive ¶ added in v0.7.0
ValidateUserDrive normalizes d in place and validates it, in the same shape validateGovernanceAssignment applies to an assignment: trim, lowercase where the value is matched case-insensitively downstream, length, no control characters.
runnerTarget is the deployment's own Config.RunnerTarget, and the match is REQUIRED rather than advisory: a k8s drive on a Docker deployment is a row whose every resolution ends in a runtime refusal, so it is a 400 at the write instead of a 422 on somebody's run three days later. An empty or unknown target refuses every backend — a control plane that dispatches nowhere has no business registering storage for it.
It deliberately does NOT check host_root against the deployment's env ceiling (see UserDriveHostRootCheck): this package cannot read the environment, and a ceiling that lived in two places would be a ceiling one of them could forget.
func ValidateUserDriveGrant ¶ added in v0.7.0
func ValidateUserDriveGrant(g *UserDriveGrant) error
ValidateUserDriveGrant normalizes g in place and validates it, applying the SAME subject hygiene validateGovernanceAssignment applies — trim, length, no control characters, and for a GROUP subject the shared CanonicalGroupSubject the login-time snapshot itself is built with — because the three tables are written against the identical subject vocabulary and resolved through the identical capabilitySubjects call. A subject normalized one way here and another way there is a row that silently never matches.
Types ¶
type ADOEntraConfig ¶ added in v0.7.10
type ADOEntraConfig struct {
// TenantID is the Entra tenant every sign-in for this row goes to, as a
// GUID. A well-known alias ("common", "organizations") is refused at the
// write boundary: those mean "whichever tenant the person belongs to",
// which is the opposite of pinning one.
TenantID string `json:"tenant_id"`
// ClientID is the app registration the sign-in names, as a GUID.
ClientID string `json:"client_id"`
// CapabilityCeiling is the widest set of capabilities a run on this row may
// ever hold. Required and non-empty when the block is present — an empty
// ceiling would have to mean either "nothing" or "everything", and one of
// those readings is the empty-Lanes trap again.
CapabilityCeiling []adoscope.Capability `json:"capability_ceiling,omitempty"`
// DefaultProfile is what a run gets when it asks for nothing. Empty reads
// as adoscope.ProfileRead.
DefaultProfile []adoscope.Capability `json:"default_profile,omitempty"`
// TokenMode is how the token reaches the run. Empty reads as
// ADOTokenModeBearer, the only accepted mode.
TokenMode ADOTokenMode `json:"token_mode,omitempty"`
// RESTAPI is whether Azure DevOps REST calls are brokered on this lane at
// all, or only git traffic. A POINTER because the default is TRUE and a
// bool's zero value is false: nil is "unset, so brokered", which keeps a
// row written without the field on the behaviour the field describes.
// Read it through RESTAPIEnabled, never directly.
RESTAPI *bool `json:"rest_api,omitempty"`
}
ADOEntraConfig is the Entra lane's configuration on one Azure DevOps row: which tenant and app the run authenticates against, the widest access that row may ever ask for, what it asks for by default, and how the token is presented.
THE CEILING IS NOT A DEFAULT. CapabilityCeiling is the most a run on this row could ever be granted, and DefaultProfile is what it gets when nothing asks for more — two separate numbers because an admin who is willing to permit a bypass ONCE, reviewed, must not thereby make every run able to bypass. The write boundary holds DefaultProfile inside CapabilityCeiling (validateProviderEntra); an empty DefaultProfile reads as adoscope.ProfileRead, so the read-only default is a property of the catalogue rather than a field someone remembered to fill in.
func (*ADOEntraConfig) Profile ¶ added in v0.7.10
func (c *ADOEntraConfig) Profile() []adoscope.Capability
Profile is the capabilities a run on this row gets when it asks for nothing — DefaultProfile, or the catalogue's read-only profile when that is empty. The fallback lives here so no call site invents its own idea of "default".
func (*ADOEntraConfig) RESTAPIEnabled ¶ added in v0.7.10
func (c *ADOEntraConfig) RESTAPIEnabled() bool
RESTAPIEnabled is the ONE spelling of the rest_api default: unset is enabled. A nil receiver answers true as well, so a caller holding a row with no Entra block reads the same answer as one whose block says nothing.
type ADOTokenMode ¶ added in v0.7.10
type ADOTokenMode string
ADOTokenMode is HOW an Entra-lane run presents itself to Azure DevOps.
It has ONE legal value today and is kept anyway. Both write doors decode site config strictly (unknown fields are a 400), so dropping the field would turn a document that says `"token_mode": "bearer"` — explicitly, as an MDM profile might — into an opaque "unknown field" refusal; and a named field is where a refused mode can be refused with its REASON rather than as noise.
const ( // ADOTokenModeBearer sends the Entra access token itself. The zero value, // so an unset field is the bearer mode, and the only mode accepted. ADOTokenModeBearer ADOTokenMode = "bearer" // ADOTokenModeMintedPAT would have exchanged the Entra token for a // short-lived personal access token per run. It is REFUSED at the write // boundary and is not in ClosedADOTokenModes: Azure DevOps mints personal // access tokens only for Microsoft's own first-party clients — measured, // an app registration holding both token scopes gets 401 TF400813 on the // mint — so no Wardyn install can build it. It stays NAMED so the refusal // can say why. ADOTokenModeMintedPAT ADOTokenMode = "minted_pat" )
func (ADOTokenMode) Valid ¶ added in v0.7.10
func (m ADOTokenMode) Valid() bool
Valid reports whether m is an accepted mode.
type APIToken ¶ added in v0.6.0
type APIToken struct {
ID uuid.UUID `json:"id"`
Principal string `json:"principal"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
Groups []string `json:"groups,omitempty"`
GroupsTruncated *bool `json:"groups_truncated,omitempty"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
Token string `json:"token,omitempty"` // plaintext, create response ONLY
}
APIToken is one per-user API token (migration 0045): a long-lived bearer credential a HUMAN mints for their own scripts/CI so automation stops sharing the single deployment-wide admin token. The auth branch that accepts one (apiTokenAuth in internal/api/apitokens.go) republishes exactly the context a verified SSO session publishes, so grants, RBAC and run ownership bind to the OWNING HUMAN — never to the admin identity.
Email/Role/Groups are a SNAPSHOT of the creating session, stamped at create time the way SSHPublicKey.Role is stamped at registration: a bearer token carries no ID token, so there is nothing to re-derive them from per request. The ceiling is the same one SSHPublicKey.Role carries — a demotion does not reach an outstanding token; REVOKE it.
Groups distinguishes nil from empty exactly as the session path does (see oidcGroupsCtxKey in internal/api/http.go): nil means "snapshot unavailable", empty means "the IdP sent no usable groups".
Token carries the PLAINTEXT credential and is populated on exactly one response — the create call — and is never stored, listed or logged. Every other path leaves it empty, and `omitempty` keeps it out of those bodies. GroupsTruncated is that snapshot's PF-26 completeness marker (migration 0052's api_tokens.groups_truncated), and it is THREE-VALUED on purpose:
true — the snapshot was cut off by the cookie byte cap at mint. false — the snapshot is complete. nil — UNKNOWN: a token minted before 0.7, when nothing recorded the bit.
nil is NOT false. A pre-0.7 token's snapshot may well have been truncated, and reading it as complete lets the holder silently shed a group-assigned governance profile — the exact evaporation this marker closes. Consumers treat nil as TRUNCATED (fail closed); the cost is one re-mint, and only on a deployment that actually assigns profiles to groups.
type ActorType ¶
type ActorType string
ActorType distinguishes who performed an action in the audit stream. This is the attribution field the incumbents lack.
type AgentMechanism ¶ added in v0.7.2
type AgentMechanism string
AgentMechanism is the ONE model-access lane an agent's runs may use. The set is drawn from the lanes that already exist at dispatch (internal/api's resolveLLMTransport): the three Anthropic/OpenAI ones, Bedrock's four sub-lanes, and none (BYOA — Wardyn wires no model credential).
The four bedrock_* values FOLD to the single coarse "bedrock" the harness catalog's ProviderTypes map is keyed by; they are spelled out here because the sub-lane is what the admin chose and what a refusal must name — "Bedrock is configured" was exactly the sentence that told a customer nothing.
const ( // AgentMechanismAnthropicSubscription is the managed container-login // subscription token (setup-token), injected at the proxy. AgentMechanismAnthropicSubscription AgentMechanism = "anthropic_subscription" // AgentMechanismAnthropicAPIKey is the stored anthropic-api-key lane. AgentMechanismAnthropicAPIKey AgentMechanism = "anthropic_api_key" // AgentMechanismOpenAIAPIKey is the stored openai-api-key lane. AgentMechanismOpenAIAPIKey AgentMechanism = "openai_api_key" // AgentMechanismBedrockBearer is the stored bedrock-api-key bearer lane. AgentMechanismBedrockBearer AgentMechanism = "bedrock_bearer" // AgentMechanismBedrockSSO is a captured AWS SSO session — the ONE lane with // a per-principal capture path, so the only one CredentialSourcePerUser may // name. AgentMechanismBedrockSSO AgentMechanism = "bedrock_sso" // AgentMechanismBedrockEnv is the daemon's own AWS environment. AgentMechanismBedrockEnv AgentMechanism = "bedrock_env" // AgentMechanismBedrockAWSDir is the host ~/.aws mount (read-only by // contract, never refreshed by Wardyn). AgentMechanismBedrockAWSDir AgentMechanism = "bedrock_aws_dir" // AgentMechanismNone is BYOA: Wardyn wires no model credential and the image // authenticates however it likes. The only mechanism a non-catalog agent // (a WARDYN_AGENT_IMAGES key) may name, because no code path can honour // another one for it. AgentMechanismNone AgentMechanism = "none" )
func (AgentMechanism) ProviderType ¶ added in v0.7.2
func (m AgentMechanism) ProviderType() string
ProviderType folds m onto the COARSE ai-integration type the harness catalog's ProviderTypes map is keyed by: every bedrock_* sub-lane becomes "bedrock", and the three Anthropic/OpenAI names map 1:1 (they are already that vocabulary). AgentMechanismNone folds to itself and is never looked up in that map — it is decided by the harness row's NoManagedAuth flag instead.
func (AgentMechanism) Valid ¶ added in v0.7.2
func (m AgentMechanism) Valid() bool
Valid reports whether m is one of the closed mechanisms.
type AgentProvider ¶ added in v0.7.2
type AgentProvider struct {
// ID is the --agent value this row governs: a harness-catalog id
// ("claude-code", "codex-cli", "none") OR a WARDYN_AGENT_IMAGES key.
// The image-map half is deliberate — harnessByID documents an
// image-map-only custom agent as supported, so a block that refuses agents
// without a row must let the admin write a row for their own image.
ID string `json:"id"`
// Disabled turns the row off without deleting it — negative-sense like
// GitProvider.Disabled, so the zero value is ENABLED. A disabled row is the
// admin saying "off": its runs are refused and the console renders it
// unavailable, never hidden.
Disabled bool `json:"disabled,omitempty"`
// Mechanism is the one lane this agent's runs may use. There is NO
// cross-mechanism fallback once it is declared: a run whose lane is dead is
// refused naming the lane and its state, never silently served by a
// different vendor's credential (the failure that cost a customer a session).
Mechanism AgentMechanism `json:"mechanism"`
// CredentialSource is whose credential that lane uses. Empty reads as
// CredentialSourceShared — today's behaviour.
CredentialSource CredentialSource `json:"credential_source,omitempty"`
// SSOStartURL is the AWS access portal every principal signs in against,
// REQUIRED when Mechanism is bedrock_sso and CredentialSource is per_user and
// forbidden otherwise. ADMIN-OWNED on purpose: a member's login launch
// ignores the request's start URL and uses this one, so a member cannot bind
// their capture to a foreign IdP/account, and an org URL leaves the member's
// typing surface.
SSOStartURL string `json:"sso_start_url,omitempty"`
// SSOAccountID and SSORoleName pin WHICH AWS account and role a sign-in for
// this row may capture. Optional, set together, permitted only where
// SSOStartURL is (bedrock_sso + per_user). Admin-owned for the same reason
// the start URL is: the sign-in proposes, the roster disposes.
SSOAccountID string `json:"sso_account_id,omitempty"`
SSORoleName string `json:"sso_role_name,omitempty"`
}
AgentProvider is one agent row: which agent, whether it is offered, the ONE lane its runs reach a model on, and whose credential that is.
type AgentProviders ¶ added in v0.7.2
type AgentProviders struct {
// Agents are the per-agent rows. A row is the admin saying "this agent is
// offered here, on this lane"; an agent with NO row is not offered at all
// once the block exists.
Agents []AgentProvider `json:"agents,omitempty"`
}
AgentProviders is the org's AGENT-ENABLEMENT POLICY: which coding agents this deployment offers, how each one reaches its model, and whether that credential is one for everyone or one per person. It is a sub-object of the SiteConfig singleton, shaped byte-for-byte on WorkspaceProviders (see that type for the no-DDL / MDM-deliverable doctrine, which applies here word for word).
It exists because availability was an IMAGE MAP with no auth semantics (WARDYN_AGENT_IMAGES is boot env; the harness catalog in internal/api/harness.go is static code expressing agent x provider POSSIBILITY, not admin choice), so "Claude Code is offered here, authenticated via AWS Bedrock SSO, one credential per user" was not a statement the product could hold. A customer's admin captured one AWS SSO session that silently backed every member's runs, and the member had no way to see whose credential was dying.
THE ZERO VALUE IS LEGACY OPEN MODE, and that is load-bearing: with no block, every predicate over it answers exactly what it answered before the block existed — the image map, the operator credential and the whole precedence chain are byte-for-byte 0.7.1 (see the api package's agentProvidersConfigured / agentProviderFor).
VALIDATION DELIBERATELY LIVES IN internal/api (validateAgentProviders): a row's id is admitted against the harness catalog OR the boot image map, and its mechanism against that catalog row's ProviderTypes — server state this package cannot see. This file is types and closed enums only.
func (*AgentProviders) Empty ¶ added in v0.7.2
func (p *AgentProviders) Empty() bool
Empty reports whether p carries no policy at all — the shape a caller PUTs to CLEAR the block, and what the write boundary normalizes back to nil so a later GET does not render "agent_providers":{} on every read forever (SiteConfig.AgentProviders is a pointer for the same reason).
type AgentRun ¶
type AgentRun struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"` // human principal (token `sub`)
Agent string `json:"agent"` // e.g. "claude-code", "codex-cli"
Repo string `json:"repo"` // e.g. "org/name"
Task string `json:"task"` // human task description
// Title is the run's human NAME. Runs that share a title are grouped in the
// console's run list. Empty for legacy rows and for system runs (scan,
// harness login, workspace record/verify) — the console falls back to Task,
// which is the only identity an untitled run has ever had.
Title string `json:"title,omitempty"`
// Description is optional free-text context: why this run exists. Never
// interpreted by the control plane, only displayed.
Description string `json:"description,omitempty"`
PolicyID *uuid.UUID `json:"policy_id,omitempty"`
ConfinementClass ConfinementClass `json:"confinement_class"`
State RunState `json:"state"`
SPIFFEID string `json:"spiffe_id"` // spiffe://<trust-domain>/agent-run/<id>
RunnerTarget string `json:"runner_target"` // "docker"
SandboxRef string `json:"sandbox_ref,omitempty"`
// Image is the RESOLVED sandbox image this run dispatched with (convention
// image, devcontainer build, workspace-built, or BYOI-wrapped), persisted
// for provenance. Written by a scoped update after image resolution; empty
// for legacy rows and runs that never reached resolution.
Image string `json:"image,omitempty"`
// Interactive marks a run created for human-driven use: the sandbox is brought
// up RUNNING but no agent task is exec'd and no completion watcher is started
// (the human drives via `wardyn attach`). A non-interactive run execs the agent
// with the task and is watched to completion. This is a first-class,
// sandbox-determining choice — see internal/api dispatch.
Interactive bool `json:"interactive"`
// WorkspacePath is the primary host directory this run operates in (the first
// local WorkspaceMount source resolved from its policy), denormalized here so
// the control plane can DISCOURAGE — warn, never block — launching a second
// independent agent against a host directory another active run already uses.
// Empty for runs with no local host workspace (git-clone / ephemeral).
WorkspacePath string `json:"workspace_path,omitempty"`
// WorkspaceID, when set, marks this run as a governed SCAN run for that
// onboarded workspace: the driver runs wardyn-scan after cloning instead of the
// agent, and the scan-result endpoint persists the derived profile onto this
// workspace from this TRUSTED linkage (not sandbox input). Nil for ordinary runs.
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
// WorkspaceIDs is the READ-ONLY denormalization of the onboarded workspaces
// this run's RESOLVED policy spec referenced at create time
// (referencedWorkspaces over spec.WorkspaceMounts + spec.WorkspaceRepos).
// Set only by handleCreateRun (internal/api/runs.go); the four internal
// step-run call sites (record/verify, scan, harness login, probe) leave it
// nil. Distinct from WorkspaceID above, which stays the scan/verify/
// record-only TRUSTED linkage a sandbox upload authorizes on: this column
// grants nothing, is never sandbox input, and exists only to answer "which
// workspace(s) does an `always` egress-approval decision persist to". Nil
// for a run that references no onboarded workspace.
WorkspaceIDs []uuid.UUID `json:"workspace_ids,omitempty"`
// SourceID is the TRUSTED run→library-source linkage for a per-source scan
// run (the three-tier retarget): the scan-facts upload authorizes on it the
// way workspace runs authorize on WorkspaceID. Never set by user runs.
SourceID *uuid.UUID `json:"source_id,omitempty"`
// AutoStopAfterSec is the run's EFFECTIVE idle auto-stop cap, captured from the
// resolved RunPolicySpec at creation (frozen for the run's life). The idle
// reaper reads it from the run row so a run launched with an inline/default
// policy — which has no stored policy_id to JOIN — is reaped like any other.
// 0 = never auto-stop; <0 = explicitly never (interactive). See adapters.go.
AutoStopAfterSec int `json:"auto_stop_after_sec,omitempty"`
// AgentExecID is the docker exec id of the agent process for exec-based runs
// (the default idle-container + `docker exec` path). Empty for exec-less /
// main-process substrates (krun) and before Exec runs. Persisted so the crash
// reconciler can observe AGENT liveness via ExecInspect across a wardynd
// restart: for an idle-container run the container is `sleep infinity`, so
// container liveness != agent liveness, and the exec id otherwise lived only in
// the driver's in-memory map — lost on restart, stranding the run.
AgentExecID string `json:"agent_exec_id,omitempty"`
// FailureHint is an operator-facing one-line reason a run FAILED, stamped by
// the dispatch/create failure paths (failAndRevoke). Precedent:
// record.go RecordResult.FailureHint. Without it a run that dies BEFORE the
// agent starts (an image that resolves control-plane-side but not on the
// daemon, a lost sandbox ref, an opaque-LLM inspection refusal) is just a
// reason-less FAILED badge — the real reason lived only in an audit row. Empty
// for every run that did not fail this way (a clean FAILED-by-nonzero-exit
// carries its exit code instead, and success/terminal-by-kill carry nothing).
// Display-only; never interpreted by the control plane.
FailureHint string `json:"failure_hint,omitempty"`
// StatusDetail is what the SUBSTRATE says this run is waiting on while it is
// STARTING, in the substrate's own words — the
// `<component>: <Reason>[: <message>]` shape the kubelet's container status,
// the scheduler's PodScheduled condition and docker's first pull all render
// into ("agent: ImagePullBackOff: …", "pod: Unschedulable: …", "image:
// Pulling: <ref>"). Written from inside CreateSandbox (migration 0063,
// runner.SandboxSpec.OnWaiting), once per CHANGE of reason, because the whole
// STARTING window happens before the run has a sandbox_ref and nothing
// outside the driver can ask a substrate anything.
//
// DISPLAY-ONLY, and never interpreted by the control plane: it is whatever
// the platform said. It is also never CLEARED by a write — the read path
// blanks it for any run that is not STARTING (except a run that FAILED on a
// TERMINAL reason, where the reason IS the failure and a reader that missed
// the last STARTING poll would otherwise see a reason-less FAILED badge), so
// the last reason survives on the row for a postmortem without the console
// ever narrating a finished run's old wait.
StatusDetail string `json:"status_detail,omitempty"`
// StatusReason is the bare reason token out of StatusDetail
// (`ContainerCreating`, `ImagePullBackOff`, `Unschedulable`, `Pulling`,
// `Pending`…) — DERIVED at read and never stored, the same way HasRecording
// and its two siblings below are. It exists so that everything which is not a
// person (metrics, the live specs, later automation) reasons on a token while
// the console owns the sentence; the raw StatusDetail stays on the wire
// because only it carries the registry's or the scheduler's own message.
StatusReason string `json:"status_reason,omitempty"`
// HasRecording, RecordingBytes and RecordingDurationSec (R4-F077) are
// DERIVED, never stored: projected by handleListRuns/handleGetRun from
// RecordingStore.StatAndTail(id) after the store read — but ONLY when the
// request opts in with ?include=recording_meta (wantsRecordingMeta,
// runs_recording_meta.go): it costs one StatAndTail call per run in the
// response, and handleListRuns also backs the Runs board's 3s poll at
// limit=1000, which renders none of these three fields. Without the flag
// (or with RecordingStore nil) all three sit at their zero value, same as
// "not requested". RecordingBytes is the exact payload size;
// RecordingDurationSec is the last captured output frame's elapsed time,
// read from a small tail of the payload (see
// internal/recording.LastOutputElapsed) — the same number recordings.ts's
// former client-side probe used to compute by fetching the WHOLE document.
// HasRecording is the ONLY "no recording" signal: a zero
// RecordingDurationSec on a has_recording=true run is a real, header-only
// cast that captured no output, never "unknown" or "none".
HasRecording bool `json:"has_recording"`
RecordingBytes int64 `json:"recording_bytes,omitempty"`
RecordingDurationSec float64 `json:"recording_duration_sec,omitempty"`
}
AgentRun is one governed execution of a coding agent on behalf of a human. Every run gets its own identity (SPIFFE ID), its own credential grants, and its own audit trail.
type ApprovalDecision ¶ added in v0.5.0
type ApprovalDecision struct {
State ApprovalState
DecidedBy string
Reason string
// Scope and ExpiresAt are meaningful only for an egress_domain approval;
// every other kind leaves them at the zero value. See ApprovalScope.
Scope ApprovalScope
ExpiresAt *time.Time
}
ApprovalDecision is what a human — or the sweeper, for a stale-PENDING expiry — is deciding: which state the approval moves to, who decided and why, and (egress_domain approvals only) how far the decision reaches. It is threaded as ONE value through DecideApproval and ApprovalService.Decide rather than growing those signatures to five-plus positional parameters.
ExpireStale is the one caller that leaves Scope at its zero value (""), never ScopeRun: an expiry is a sweep nobody decided, and ApprovalScope's own doc already explains why an unmade decision must never be asserted as "run"-scoped.
type ApprovalKind ¶
type ApprovalKind string
ApprovalKind enumerates what a human is being asked to approve.
const ( ApprovalCredential ApprovalKind = "credential" ApprovalEgressDomain ApprovalKind = "egress_domain" ApprovalToolCall ApprovalKind = "tool_call" // ApprovalCredentialReauth: the run's own model credential lapsed MID-RUN // and cannot be renewed, so the proxy is HOLDING the sandbox's next // credential exchange while the credential's owner signs in again // (internal/api/injection_awssso.go, internal/egress/proxy/credhold.go). // // It is NOT a decision. Nobody approves or denies it: it is resolved by the // owner completing the sign-in, which is why Server.decide answers 409 for // this kind and the console renders a door instead of an Approve/Deny pair. // The row still moves to APPROVED — so every existing list, count and // terminal-cascade reader works unchanged — but through ResolveReauth and // its own credential.reauth.resolved audit action, never approval.decide. ApprovalCredentialReauth ApprovalKind = "credential_reauth" )
type ApprovalRequest ¶
type ApprovalRequest struct {
ID uuid.UUID `json:"id"`
RunID uuid.UUID `json:"run_id"`
GrantID *uuid.UUID `json:"grant_id,omitempty"`
Kind ApprovalKind `json:"kind"`
RequestedScope json.RawMessage `json:"requested_scope"`
State ApprovalState `json:"state"`
RequestedAt time.Time `json:"requested_at"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
DecidedBy string `json:"decided_by,omitempty"`
MintedJTI string `json:"minted_jti,omitempty"`
Reason string `json:"reason,omitempty"`
// DecisionScope is how far the human's decision reaches: once (one
// connection), run (rest of this run — the default and the legacy meaning),
// until (this run, until DecisionExpiresAt), or always (persisted onto the
// workspace so future runs inherit it). Empty on a PENDING row — a decision
// nobody has made yet has no scope — which is why the column is
// NOT NULL DEFAULT ” rather than DEFAULT 'run'.
//
// Named decision_scope, NOT scope: RequestedScope above is the unrelated
// host JSON, and it is part of the PENDING dedup unique index.
DecisionScope ApprovalScope `json:"decision_scope,omitempty"`
// DecisionExpiresAt is set only when DecisionScope is until. Nullable, so it
// scans into a pointer the way DecidedAt does. NOT the same clock as the
// EXPIRED state / approval.expire sweeper, which ages out stale PENDING
// requests — this bounds a GRANT that was actually made.
DecisionExpiresAt *time.Time `json:"decision_expires_at,omitempty"`
}
ApprovalRequest is a blocking human-in-the-loop gate. RequestedScope is EXACTLY what the approver saw; the broker writes MintedJTI back in the same transaction as the mint, yielding the provable join "approval X by human Y minted credential Z".
type ApprovalScope ¶ added in v0.5.0
type ApprovalScope string
ApprovalScope is how far a human's approve/deny decision reaches. It is ORTHOGONAL to FirstUseMode: that policy setting decides whether an unknown host is escalated to a human at all; this decides the blast radius of the answer. egress_domain approvals carry any of the four; a tool_call carries none (the clamp bounds it); a CREDENTIAL approval carries ScopeRun and nothing else — that one value is the per-run credential lease (B2), read RAW by broker.leaseCoversRemint so a git_pat approved once is re-mintable for the rest of the run instead of raising a fresh approval per git operation.
const ( // ScopeOnce releases exactly one connection. For HTTPS that is one CONNECT // tunnel (which may carry many requests); for plain HTTP the proxy // re-evaluates per request, so it really is one request there. ScopeOnce ApprovalScope = "once" // ScopeRun holds for the rest of the run. This is the legacy meaning of // every decision made before scopes existed, and stays the default. ScopeRun ApprovalScope = "run" // ScopeUntil holds for the rest of the run OR until DecisionExpiresAt, // whichever comes first. Requires DecisionExpiresAt. ScopeUntil ApprovalScope = "until" // ScopeAlways additionally persists the host onto the run's workspace // (approved_egress / denied_egress) so future runs inherit it. Operator-only. ScopeAlways ApprovalScope = "always" )
func (ApprovalScope) Normalize ¶ added in v0.5.0
func (s ApprovalScope) Normalize() ApprovalScope
Normalize resolves a stored/wire value for every runtime read, and it treats its two "not a known scope" cases DIFFERENTLY on purpose:
- EMPTY means "an old client, or a row written before this column existed". Those already meant run-scoped, so widening them to anything else would silently change shipped behavior. Empty => ScopeRun.
- UNKNOWN NON-EMPTY can only come from a NEWER control plane writing a scope this binary does not understand (Valid() rejects garbage at the boundary). Treating that as ScopeRun would be fail-OPEN across versions, so it floors to the tightest scope instead. Unknown => ScopeOnce.
Honest caveat: "tightest" is allow-shaped. On a DENY, once is the WIDER choice (it re-raises; run stays denied), so an unknown scope from the future turns a cached deny into a re-raise. That fails closed at the proxy — the re-raise is denied again under deny_with_review — but it does cost queue noise, so this is not uniformly fail-closed and should not be described as if it were.
func (ApprovalScope) Valid ¶ added in v0.5.0
func (s ApprovalScope) Valid() bool
Valid reports whether s is empty (unset => legacy run-scoped) or one of the four known scopes. Used to reject a garbage value at the API write boundary, mirroring FirstUseMode.Valid; runtime reads still fail closed via Normalize.
type ApprovalState ¶
type ApprovalState string
ApprovalState is the approval lifecycle.
const ( ApprovalPending ApprovalState = "PENDING" ApprovalApproved ApprovalState = "APPROVED" ApprovalDenied ApprovalState = "DENIED" ApprovalExpired ApprovalState = "EXPIRED" // ApprovalCancelled: the RUN this approval belongs to reached a terminal // state (killed, completed, failed, stopped) while the approval was still // PENDING, so the question it asked can no longer be answered — approving it // would mint a credential for a sandbox that is gone. Distinct from DENIED // (a human refused) and from EXPIRED (a sweeper aged it out undecided): // nobody decided this one and nothing was refused. Terminal, like the other // three, and every reader treats it as a refusal to proceed. ApprovalCancelled ApprovalState = "CANCELLED" )
type ArtifactOverride
deprecated
type ArtifactOverride struct {
BaseURL string `json:"base_url"`
TokenSecretRef string `json:"token_secret_ref,omitempty"`
}
ArtifactOverride is one ecosystem's corporate artifact-registry redirect: the base URL to emit into that ecosystem's config (.npmrc/pip.conf/cargo config/ settings.xml/GOPROXY/nuget.config) plus an optional secret ref for a token injected proxy-side (the sandbox never holds the value).
Deprecated: superseded by EgressRedirect (BaseURL -> To, unchanged semantics). See SiteConfig.ArtifactOverrides for why the type is kept.
type AuditEvent ¶
type AuditEvent struct {
ID uuid.UUID `json:"id"`
Time time.Time `json:"time"`
RunID *uuid.UUID `json:"run_id,omitempty"`
ActorType ActorType `json:"actor_type"`
Actor string `json:"actor"` // human sub, agent SPIFFE ID, or component name
Action string `json:"action"` // dotted verb, e.g. "credential.mint", "egress.deny", "kernel.process.exec"
Target string `json:"target,omitempty"`
Outcome string `json:"outcome"` // "success" | "failure" | "denied"
SourceIP string `json:"source_ip,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
// PrevHash/RowHash are the tamper-evidence chain (migration 0047):
// RowHash = SHA-256(PrevHash || canonical serialization of the fields
// above), hex, computed BY POSTGRES in the audit_events BEFORE INSERT
// trigger — never by the caller, who therefore cannot choose them.
//
// They are populated on the WRITE path only (InsertAuditEvent fills them
// from RETURNING), which is what carries the current head hash out to the
// audit sinks so a SIEM can detect a later truncation. The paginated READ
// paths deliberately do not select them, so both are empty on anything
// served by GET /audit — hence omitempty. The chain is verified through
// GET /api/v1/audit/chain/verify, not by reading rows back.
PrevHash string `json:"prev_hash,omitempty"`
RowHash string `json:"row_hash,omitempty"`
}
AuditEvent is one append-only audit record. Every credential mint/revoke, approval decision, policy change, egress decision, and lifecycle change emits one. Events carry the delegation chain (human sub + agent run).
Action namespaces (the dotted-verb prefix discriminates the source stream):
credential.* identity.* approval.* policy.* egress.* run.* recording.*
— control-plane / agent self-report events (Postgres event log + PTY).
kernel.* — the eBPF/Tetragon GROUND-TRUTH stream (the tamper-proof
second stream). Emitted ONLY by the host-scoped sensor
(cmd/wardyn-tetragon-ingest) via POST /api/v1/internal/
groundtruth, which FORCES actor_type=system +
actor="wardyn-tetragon-ingest" and rejects any action that
does not carry the "kernel." prefix. The defined kernel.*
actions are:
kernel.process.exec — observed execve
kernel.network.connect — observed outbound TCP connect
kernel.file.write — observed write to a sensitive path
kernel.sensor.heartbeat— sensor liveness (run_id NULL)
kernel.sensor.blind — host eBPF blind to a run (CC3/Kata)
Data shape for the kernel.* (ebpf) stream. audit_events.data is JSONB, so this requires NO schema change — it is a documented convention over the existing column. Every kernel.* event carries:
{
"stream": "ebpf", // discriminates from agent self-report
"subtype": "process_exec" | "network_connect" | "file_write" | ...,
"cgroup_id": <uint64>, // kernel cgroup id (omitempty)
"container_id": "<id>", // attributed container (omitempty)
"argv": [...] | "dst": "ip:port" | "path": "/...", // kind-specific
"loader": true, // exec of a dynamic linker (ld-linux/
// ld-musl) — the documented ld-linux/
// mmap bypass surface, FLAGGED not blocked
"correlation": "mapped" | "unmapped", // unmapped => run_id NULL, never
// silently dropped (visible blindness)
"reason": "...", // sensor.blind / failure detail (omitempty)
"dropped_total": <uint64>, // heartbeat only: events lost before
// reaching the control plane — POST
// backpressure OR an oversized/
// unterminated export line (B12b-F9)
"observed_total": <uint64>, // heartbeat only: kernel events mapped off the tail
"dropped_unmapped": <uint64> // heartbeat only: events dropped as
// uncorrelated — nonzero with
// observed_total 0 means correlation is
// broken, NOT that the sensor is blind
}
Outcome stays within the existing CHECK ("success"|"failure"|"denied"): the kernel stream uses "success" for normal observations and "failure" for the unexpected — an unmapped/escape signal such as a connect to a private/ link-local/metadata (non-proxy) address, or a sensor.blind coverage gap. It never uses "denied": this is a DETECTION stream, it does not block. See internal/groundtruth for the canonical action/data definitions.
type BaseImageEntry ¶ added in v0.5.0
type BaseImageEntry struct {
ID uuid.UUID `json:"id"`
Kind string `json:"kind"` // "registry" | "custom" | "byo"
Name string `json:"name"`
// Image is the ref: the image itself (registry/byo) or the FROM (custom).
Image string `json:"image"`
// Steps are Dockerfile lines attached to a "custom" Image. NOT CURRENTLY
// APPLIED at build time — see WorkspaceBaseImage.Steps's doc (workspace.go)
// for why (host-side build RCE, outside every confinement tier). Stored so
// the catalog row round-trips; never layered onto Image today.
Steps []string `json:"steps,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
BaseImageEntry is one tier-2 catalog row: a shared, reusable image an operator saved. Kind is registry|custom|byo — NEVER "recommended", which is a per-workspace DERIVED build (computed from that workspace's merged source profiles) and so has no catalog identity; the store enforces this with a CHECK constraint so it is structural, not a convention.
type CapabilityEffect ¶ added in v0.6.0
type CapabilityEffect string
CapabilityEffect is a grant's direction. Closed and complete; DENY BEATS ALLOW at resolution time, with no user-vs-group precedence — "Bob's user allow overrode the group deny" is a breach report, not a feature.
const ( CapabilityAllow CapabilityEffect = "allow" CapabilityDeny CapabilityEffect = "deny" )
func (CapabilityEffect) Valid ¶ added in v0.6.0
func (e CapabilityEffect) Valid() bool
Valid reports whether e is allow or deny.
type CapabilityGrant ¶ added in v0.6.0
type CapabilityGrant struct {
ID uuid.UUID `json:"id"`
SubjectType CapabilitySubjectType `json:"subject_type"`
Subject string `json:"subject"`
Capability string `json:"capability"`
Value string `json:"value"`
Effect CapabilityEffect `json:"effect"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
}
CapabilityGrant is one row of the permissioning grant list (migration 0042): "subject S may (or may not) use capability C at value V".
Capability is a PLAIN STRING here, not a typed enum, and that is deliberate: the closed kind set lives in exactly one Go slice in internal/api (capabilityKinds) and is validated at the write boundary, so a fifth kind is a constant plus a call site with no schema change. A stored kind this binary does not know is inert — no resolver ever asks for it.
Value's meaning is per kind: a host or "*.suffix" for egress_host, an exact secret name / workspace uuid / image ref for the others, and "*" is the per-kind wildcard everywhere.
type CapabilitySubjectType ¶ added in v0.6.0
type CapabilitySubjectType string
CapabilitySubjectType names WHO a capability grant is written against (migration 0042). Closed and complete — its DB CHECK is pinned against these constants by internal/db's TestClosedEnumChecksMatchConstants.
const ( // CapabilitySubjectUser is one human, named by either their lowercased OIDC // "sub" or their email — a grant on EITHER matches, so an admin can write // down the identity they actually know rather than the one the IdP prefers. CapabilitySubjectUser CapabilitySubjectType = "user" // CapabilitySubjectGroup is one entry of the login-time union of the ID // token's roles+groups claims (see oidc.Session.Groups). Entra App Roles are // grantable through this without any extra configuration. CapabilitySubjectGroup CapabilitySubjectType = "group" // CapabilitySubjectAll is every signed-in human — the baseline for an IdP // that emits no usable groups claim. Subject is "" for this type. CapabilitySubjectAll CapabilitySubjectType = "all" )
func (CapabilitySubjectType) Valid ¶ added in v0.6.0
func (t CapabilitySubjectType) Valid() bool
Valid reports whether t is one of the three subject types. Used to reject a garbage value at the API write boundary, mirroring ApprovalScope.Valid.
type ConfinementClass ¶
type ConfinementClass string
ConfinementClass declares how strongly a sandbox substrate can actually confine an agent. Policy may refuse high-trust credential scopes to weak classes; the UI must always display the class. See threatmodel/.
const ( // CC1: hardened shared-kernel runc (userns, seccomp, AppArmor, cap-drop). CC1 ConfinementClass = "CC1" // CC2: gVisor userspace kernel (the default — needs a native Docker engine // with the runsc runtime registered; see `wardyn setup wall`). CC2 ConfinementClass = "CC2" // CC3: Kata microVM (requires /dev/kvm). CC3 ConfinementClass = "CC3" )
func (ConfinementClass) Rank ¶
func (c ConfinementClass) Rank() int
Rank orders Confinement Classes weakest→strongest (CC1<CC2<CC3). Unrecognised values rank 0 (below CC1) so they never satisfy a real minimum — callers that gate on a minimum class fail closed. Matching is exact; normalise the string first if the input is untrusted.
type CredentialGrant ¶
type CredentialGrant struct {
ID uuid.UUID `json:"id"`
RunID uuid.UUID `json:"run_id"`
CreatedAt time.Time `json:"created_at"`
Spec GrantSpec `json:"spec"`
}
CredentialGrant records what a run is ELIGIBLE for. Eligibility is not issuance: minting happens only via the broker, and for RequiresApproval grants only inside the same DB transaction that verifies an APPROVED ApprovalRequest for this run+scope.
type CredentialSource ¶ added in v0.7.2
type CredentialSource string
CredentialSource says WHOSE credential an agent's runs use.
const ( // backs every run. The zero value, so an unset field is 0.7.1's behaviour. CredentialSourceShared CredentialSource = "shared" // CredentialSourcePerUser is one credential per principal, captured by that // person through the same device-code login sandbox an admin uses. Permitted // for AgentMechanismBedrockSSO only in 0.7.2 — it is the one mechanism with a // per-principal capture path. CredentialSourcePerUser CredentialSource = "per_user" )
func (CredentialSource) Valid ¶ added in v0.7.2
func (s CredentialSource) Valid() bool
Valid reports whether s is one of the two sources.
type DriveBackend ¶ added in v0.7.0
type DriveBackend string
DriveBackend names WHERE a drive's bytes live and therefore WHICH runner can mount it. Closed and complete; migration 0054's user_drives.backend CHECK is pinned against these constants.
const ( // DriveBackendDockerVolume is a per-user Docker named volume, created by // Wardyn on first use. Managed. Docker only. DriveBackendDockerVolume DriveBackend = "docker_volume" // DriveBackendHostPath binds <host_root>/<home> from a tree the OPERATOR // mounted host-side (fstab/systemd cifs or nfs). Wardyn never performs the // share mount itself and therefore never holds a share credential. Share. // Docker only. DriveBackendHostPath DriveBackend = "host_path" // DriveBackendK8sPVC is a per-user dynamically-provisioned PVC. Managed. // Kubernetes only. DriveBackendK8sPVC DriveBackend = "k8s_pvc" // DriveBackendK8sPVCStatic is an admin-precreated PVC (typically an NFS/SMB // CSI volume). Wardyn only ever READS it: a missing claim is a refusal, not // a create. Share. Kubernetes only. DriveBackendK8sPVCStatic DriveBackend = "k8s_pvc_static" )
func (DriveBackend) Kind ¶ added in v0.7.0
func (b DriveBackend) Kind() DriveKind
Kind derives the managed/share split from the backend. An unknown backend reads as a SHARE — the conservative half, since a share is never created and never deleted by Wardyn, so a bad row cannot make the control plane provision storage.
func (DriveBackend) RunnerTarget ¶ added in v0.7.0
func (b DriveBackend) RunnerTarget() string
RunnerTarget is the ONE Config.RunnerTarget value that can mount this backend, or "" for an unknown backend. It exists so the write boundary can refuse a k8s drive on a Docker deployment (a 400) rather than storing a row whose every resolution ends in a runtime refusal.
func (DriveBackend) Valid ¶ added in v0.7.0
func (b DriveBackend) Valid() bool
Valid reports whether b is one of the four backends, mirroring CapabilitySubjectType.Valid — the API write boundary uses it in place of a second opinion about the CHECK.
type DriveKind ¶ added in v0.7.0
type DriveKind string
DriveKind is the managed-vs-share split. DERIVED from the backend and never stored: two columns that must agree are two columns that can disagree, and the disagreement would decide whether Wardyn CREATES an object or refuses because it is missing.
type DriveMount ¶ added in v0.7.0
type DriveMount struct {
// DriveID is the user_drives row this mount came from. Labels and reclaim
// sweeps group by it: an object name is per-PRINCIPAL, so it cannot answer
// "every object this drive allocated" — the question an offboarding sweep
// and a `kubectl get pvc -l` both ask.
DriveID uuid.UUID `json:"drive_id"`
Backend DriveBackend `json:"backend"`
// ObjectName is what the substrate is asked for: a Docker volume name, a
// PVC name, or the absolute host path of this principal's subdirectory.
// Derived once by the resolver (DriveObjectName) so no runner re-computes a
// hash.
ObjectName string `json:"object_name"`
// HostRoot is THIS drive's own share root (UserDrive.HostRoot), carried on a
// host_path mount so the driver can assert the bind stays inside the tree
// this drive was authored against. Empty on every other backend, which has
// no host tree at all.
//
// It does not replace the deployment's WARDYN_USER_DRIVE_HOST_ROOTS ceiling
// and is not redundant with it. The ceiling is the OPERATOR's outer bound
// over every drive at once, so on a deployment with two share drives it
// cannot tell one drive's tree from the other's: a home replaced host-side
// by a link to the same-named home under the OTHER drive's root is inside
// the ceiling, is not a root, and still carries this principal's name. Only
// the drive's own root refuses that — and only the ceiling stops an
// admin-authored row from naming a tree the operator never allowed. The
// driver asserts BOTH (runner.UserDriveHomeWithinItsRoot).
HostRoot string `json:"host_root,omitempty"`
// DriveName is the drive object's human name (UserDrive.Name), carried for
// the AUDIT row alone: run.drive.mount's Target is "<drive>/<home>" on a
// share, so the member reading their own run's rows learns which drive and
// which directory without being handed the operator's absolute host path —
// which stays in the payload's `object`, where an operator reads it.
DriveName string `json:"drive_name,omitempty"`
// StorageClass is the provisioner a managed k8s_pvc claim asks for; "" means
// the cluster default, and it is meaningless on every other backend. Carried
// here because a substrate never reads the database — the resolver hands it
// everything a mount needs (UserDrive.StorageClass).
StorageClass string `json:"storage_class,omitempty"`
// HomeName is the per-user segment ObjectName was built from, carried for
// labels and for the audit row an operator reads when reclaiming.
HomeName string `json:"home_name"`
// SubjectHash is the non-PII fingerprint of the principal this mount was
// resolved for (types.DriveSubjectHash). It is DEFENCE IN DEPTH over the
// managed-backend collision the write boundary already refuses: an object
// name is per-HOME, so a stored row that predates that refusal can still
// resolve two principals onto one volume, and the driver stamps this on the
// object it creates so the second one is refused instead of adopted.
//
// A digest, never the claim: a Docker label is echoed by `docker volume
// inspect` to anybody who can reach the daemon, so the subject itself must
// not be written there.
SubjectHash string
// Target is the reserved in-container path (runner.DriveTarget). Carried
// rather than assumed so a runner never hard-codes the string, and so the
// reserved-target refusal and the mount agree by construction.
Target string `json:"target"`
ReadOnly bool `json:"read_only,omitempty"`
SizeMiB int `json:"size_mib,omitempty"`
Enforcement StorageEnforcement `json:"enforcement"`
}
DriveMount is the RESOLVED answer the runner acts on: one principal's drive, folded with their grant's overrides and narrowed by their run request, in the shape a substrate can execute without re-reading a row or re-deriving a name.
It is a SEPARATE type from ResolvedDrive on purpose. ResolvedDrive is the admin-facing answer ("who gets what, and why") and carries the whole drive and grant rows; this is the run-facing one and carries only what a mount needs. Handing the runner the grant row would put an admin's authoring fields — priority, subject, the override tri-state — inside the sandbox wiring, where nothing may branch on them and a later field could.
ReadOnly rather than Writable, and the inversion is deliberate: every mount type the substrates already speak (runner.Mount, a k8s VolumeMount, a Docker mount) says ReadOnly, and a field that flips sense at the boundary is how a narrowing becomes a widening in a refactor.
It does NOT ride RunPolicySpec.WorkspaceMounts. A drive is per-PRINCIPAL and a workspace mount is per-ROW; keeping them apart is what keeps composer.Clamp, validateWorkspaceSources, primaryWorkspacePath and the k8s blanket host-bind refusal from each needing a drive exemption.
type DriveReclaim ¶ added in v0.7.0
type DriveReclaim string
DriveReclaim is the DECLARED INTENT for a drive's objects when a grant goes away. v1 executes it by documented operator command, not by code: nothing in the control plane deletes a volume or a PVC, and the RBAC the k8s runner asks for deliberately carries no `delete` verb.
const ( // DriveReclaimRetain keeps the object after the grant is removed — the // default, because the alternative default is data loss on an admin's // routine unassign. DriveReclaimRetain DriveReclaim = "retain" // DriveReclaimDelete records that the object SHOULD be reclaimed. It is a // note to the offboarding runbook, not an action. DriveReclaimDelete DriveReclaim = "delete" )
func (DriveReclaim) Valid ¶ added in v0.7.0
func (r DriveReclaim) Valid() bool
Valid reports whether r is one of the two reclaim intents.
type EgressRedirect ¶ added in v0.5.0
type EgressRedirect struct {
// From is the public/upstream URL or host being redirected away from.
From string `json:"from"`
// To is the corporate-internal URL or host every matching run's egress is
// substituted to. For an Ecosystem row this is also the value emitted into
// that ecosystem's config file (the old ArtifactOverride.BaseURL).
To string `json:"to"`
// TokenSecretRef optionally names a secret whose value is injected
// proxy-side as a Bearer token for To's host (the sandbox never holds it).
// Mutually exclusive with TokenIntegrationRef — validateSiteConfig rejects a
// row that sets both, since they answer the same question two ways.
TokenSecretRef string `json:"token_secret_ref,omitempty"`
// TokenIntegrationRef optionally names an Integration (SiteConfig.
// Integrations[i].ID) to take this redirect's token FROM, instead of naming
// a bare secret in TokenSecretRef.
//
// This is the seam between the two surfaces, and it exists because a private
// registry is genuinely both things: a SYSTEM you authenticate to, and
// sometimes the DESTINATION a public endpoint is rerouted to. The rule that
// keeps them from duplicating each other — the integration owns the system
// and its credential; the redirect owns rerouting a public endpoint to it.
// So a redirect points AT the integration rather than restating its secret.
//
// It carries more than the secret name: the integration's Header and Format
// come with it, so a feed that authenticates with something other than
// "Authorization: Bearer" finally can (the bare-secret path below is
// hardcoded to that shape). A ref naming nothing, a disabled row, or one with
// no header credential degrades to redirect-WITHOUT-token, exactly as a
// dangling TokenSecretRef already does — a redirect that still reroutes is
// more useful than a run that fails.
TokenIntegrationRef string `json:"token_integration_ref,omitempty"`
// Ecosystem, when set, is one of the six package-manager ecosystems
// ("npm"|"pip"|"cargo"|"maven"|"go"|"nuget") this redirect ALSO emits a
// per-tool config file for, in addition to the egress substitution and
// token injection every redirect gets. Empty means NETWORK-ONLY: no config
// file is emitted (see SiteConfig.EgressRedirects).
Ecosystem string `json:"ecosystem,omitempty"`
}
EgressRedirect is one outbound redirect: requests to From are substituted to To (From's host dropped from egress, To's host allowed), with an optional token injected proxy-side for To's host. See SiteConfig.EgressRedirects for the two-tier Ecosystem behavior. From/To are validated (validateSiteConfig) with the same control-char/shell-metacharacter/real-host discipline as the legacy ArtifactOverride.BaseURL — either a full http(s) URL (validSiteURL) or a bare host (validSiteHost); workspacescan.EmitArtifactConfig relies on that safety for its raw string interpolation into .npmrc/settings.xml/etc, so keep any future validation change there in sync.
type EphemeralProvider ¶ added in v0.7.2
type EphemeralProvider struct {
// DefaultDiskMiB is filled in at dispatch for a run that requested no size.
// 0 = unset (today's behaviour: no size is requested).
DefaultDiskMiB int `json:"default_disk_mib,omitempty"`
// MaxDiskMiB is the ceiling a larger request is clamped to. 0 = unlimited.
// A DefaultDiskMiB above a non-zero MaxDiskMiB is refused at the write
// boundary — a default that is already over the ceiling is not a policy,
// it is a contradiction.
MaxDiskMiB int `json:"max_disk_mib,omitempty"`
}
EphemeralProvider is the ephemeral-scratch policy: the size a run gets when it asks for none, and the ceiling a run asking for more is CLAMPED to (never refused — disk_mib is authored on policies, so a refusal would break every existing policy the day a limit is written).
type FirstUseMode ¶
type FirstUseMode string
FirstUseMode controls how the egress proxy handles an UNKNOWN domain — one that is neither explicitly allowed nor denied — under an allowlist policy. It widens the legacy first_use_approval boolean into three explicit modes while staying wire-compatible: UnmarshalJSON still accepts the old bool (true => deny_with_review, false => always_deny), so existing stored JSONB policies decode unchanged. It is inert under allow-all egress.
const ( // FirstUseAlwaysDeny hard-denies an unknown domain and logs it, without ever // raising it for human approval. (legacy first_use_approval=false) FirstUseAlwaysDeny FirstUseMode = "always_deny" // FirstUseDenyWithReview raises a pending approval and denies the in-flight // request immediately; once a human approves, a later retry passes. The // sandbox connection is never held open. (legacy first_use_approval=true) FirstUseDenyWithReview FirstUseMode = "deny_with_review" // FirstUseWaitForReview raises a pending approval and HOLDS the connection // open until it is approved/denied or the proxy's hold deadline passes — the // request transparently completes if approved in time. On deadline it fails // closed (403) with the approval left pending, degrading to deny_with_review. FirstUseWaitForReview FirstUseMode = "wait_for_review" )
func (FirstUseMode) Normalize ¶
func (m FirstUseMode) Normalize() FirstUseMode
Normalize maps an empty or unrecognised value to always_deny (fail closed, matching the legacy boolean zero value).
func (FirstUseMode) RaisesApproval ¶
func (m FirstUseMode) RaisesApproval() bool
RaisesApproval reports whether an unknown domain is escalated to a human rather than hard-denied (true for both review modes).
func (*FirstUseMode) UnmarshalJSON ¶
func (m *FirstUseMode) UnmarshalJSON(b []byte) error
UnmarshalJSON accepts BOTH the legacy boolean form (true => deny_with_review, false => always_deny) and the new string form, so old stored policies whose first_use_approval is a JSON boolean keep decoding without a migration.
func (FirstUseMode) Valid ¶
func (m FirstUseMode) Valid() bool
Valid reports whether m is empty (unset => default always_deny) or one of the three known modes. Used to reject a hand-authored policy with a garbage value at the API boundary; runtime reads still fail closed via Normalize.
type GitLane ¶ added in v0.7.2
type GitLane string
GitLane is one of the credential lanes a run can clone with: the GitHub App broker (repository-scoped, minted per run), a stored PAT, a stored SSH key, or the per-person Entra lane. A provider row's Lanes VETO lanes; they never mint anything and never create a credential that was not already stored.
const ( // GitLaneApp is the GitHub App broker lane (github.com only — the broker // mints per <org>/<repo> there and has no Azure DevOps equivalent). GitLaneApp GitLane = "app" // GitLanePAT is the stored git-pat-<slug> lane. GitLanePAT GitLane = "pat" // GitLaneSSH is the stored ssh-key-<slug> lane, over the SSH-over-443 // endpoints github.com and dev.azure.com publish. GitLaneSSH GitLane = "ssh" // GitLaneEntra is the Microsoft Entra lane: the run is authorized as the // PERSON, against the organisation's own tenant, with a token scoped to the // capabilities the row permits. Azure DevOps HOSTED only — an Azure DevOps // Server host does not accept Entra tokens at all. GitLaneEntra GitLane = "entra" )
type GitProvider ¶ added in v0.7.2
type GitProvider struct {
// ID is an operator-chosen stable slug, unique within Git — the same
// operator-named identity Integration.ID has (this is a config sub-object
// inside the SiteConfig singleton, not its own table, so there is no
// generated uuid), held to integrationRefRE at the write boundary. A
// capability grant names a provider by this id.
ID string `json:"id"`
// Kind says which forge's bespoke behaviour this row carries.
Kind GitProviderKind `json:"kind"`
// Disabled turns the row off without deleting its configuration —
// negative-sense like Integration.Disabled so the zero value is ENABLED.
// A present-but-disabled row is the admin saying "off": it still CLAIMS its
// hosts (admission refuses them) rather than falling through to the legacy
// ScmHosts list.
Disabled bool `json:"disabled,omitempty"`
// BaseURLs are the https addresses this row admits: a repo is admitted when
// its clone URL's scheme and host match one of these and its path equals
// that base URL's path or extends it at a "/" boundary. At least one, at
// most eight; no port, no credentials, no query or fragment, at most two
// path segments (validateWorkspaceProviders).
BaseURLs []string `json:"base_urls"`
// Lanes are the credential lanes a run may use for this provider. EMPTY
// MEANS EVERY LEGACY LANE (LegacyGitLanes) — the field narrows, it never
// widens, so an absent value is today's behaviour and a lane added later
// must be named here to be usable.
Lanes []GitLane `json:"lanes,omitempty"`
// CredentialSource is WHOSE credential this row's lanes use, in the
// AgentProvider.CredentialSource vocabulary. Empty reads as
// CredentialSourceShared — today's behaviour, where one stored PAT or key
// backs everyone. CredentialSourcePerUser requires GitLaneEntra: it is the
// only git lane with a per-person authorization path at all.
CredentialSource CredentialSource `json:"credential_source,omitempty"`
// Entra is the Entra lane's configuration. Present only on a row whose
// Lanes name GitLaneEntra, and required on one — a lane with no tenant to
// sign in to is not a lane.
Entra *ADOEntraConfig `json:"entra,omitempty"`
}
GitProvider is one git-provider row: a kind, the base URLs it admits, and the credential lanes it permits.
type GitProviderKind ¶ added in v0.7.2
type GitProviderKind string
GitProviderKind is the closed set of git forges Wardyn has bespoke behaviour for — the credential lanes, the egress bundles and the host classifiers all key on one of these two. A self-hosted forge is NOT a third kind: it is a github (GHES) or azure_devops (ADO Server) row whose base URL names its own host, because nothing on the tree can tell a GHES host from an ADO Server host from a GitLab host by its name alone.
const ( // GitProviderGitHub is github.com or a GitHub Enterprise Server host. GitProviderGitHub GitProviderKind = "github" // GitProviderAzureDevOps is dev.azure.com, a legacy org.visualstudio.com, // or an Azure DevOps Server host. GitProviderAzureDevOps GitProviderKind = "azure_devops" )
func (GitProviderKind) Valid ¶ added in v0.7.2
func (k GitProviderKind) Valid() bool
Valid reports whether k is one of the closed kinds, mirroring DriveBackend.Valid — the API write boundary uses it in place of a second opinion about the check.
type GovernanceAssignment ¶ added in v0.7.0
type GovernanceAssignment struct {
ID uuid.UUID `json:"id"`
SubjectType CapabilitySubjectType `json:"subject_type"`
Subject string `json:"subject"`
ProfileID uuid.UUID `json:"profile_id"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
}
GovernanceAssignment binds one profile to one subject (migration 0052's governance_assignments row).
SubjectType REUSES CapabilitySubjectType — the same user/group/all vocabulary capability_grants is written against, and the same one capabilitySubjects resolves a caller into. A second enum meaning the same three things is the dual-matcher drift this codebase already warns about elsewhere: two definitions of "who" that disagree by one case is how a deny stops biting.
Priority breaks ties WITHIN a tier (higher wins) — the group tier is where it earns its keep, since a member is typically in several groups at once and the admin needs to say which group's profile is the operative one. It does NOT cross tiers: a user-tier row beats every group-tier row at any priority, because an assignment is one admin explicitly naming one principal.
type GovernanceLimits ¶ added in v0.7.0
type GovernanceLimits struct {
// DenyTaskModeExec refuses task_mode=exec for a member under this profile.
DenyTaskModeExec bool `json:"deny_task_mode_exec,omitempty"`
// DenyInteractive refuses an interactive run for a member under this
// profile. Evaluated against POST-COERCION interactivity by its enforcement
// site: a request that simply OMITS the task coerces to interactive later in
// validation, so a raw "did the caller ask for interactive" read is evaded
// by leaving a field out.
DenyInteractive bool `json:"deny_interactive,omitempty"`
// MaxConcurrentRuns caps how many NON-TERMINAL runs a member under this
// profile may hold at once. 0 is unlimited — the same zero-value rule the
// two booleans follow, so limits authored before this field existed keep
// meaning what they meant.
MaxConcurrentRuns int `json:"max_concurrent_runs,omitempty"`
// DenyUserDrive refuses a USER DRIVE mount for a member under this profile:
// their run may not carry drive.enabled at all, whatever an admin has
// allocated them.
//
// A DOOR, NOT A QUOTA, which is why it is a bool beside the other two
// rather than a size beside MaxConcurrentRuns. A drive is a writable tree
// that OUTLIVES the run — the one piece of state an agent can leave behind
// — so "how big" is the wrong question for a ceiling to ask; "may this
// principal persist anything at all" is the right one, and it is the same
// shape as the two refusals above (403 with an authz.denied row, not a 422
// quota answer).
//
// Zero means unrestricted, like every other field here: a profile written
// before drives existed keeps meaning exactly what it meant, and a
// deployment that never allocates a drive is unaffected either way.
DenyUserDrive bool `json:"deny_user_drive,omitempty"`
// MaxEphemeralDiskMiB caps the EPHEMERAL scratch a member's run under this
// profile may be given — the writable layer a sandbox gets when it mounts no
// drive. 0 is unlimited, the same zero-value rule every field here follows.
//
// A CLAMP, NOT A DOOR, which is why it is a size beside MaxConcurrentRuns
// rather than a bool beside the three refusals. `disk_mib` is authored on
// POLICIES, so refusing a run that asks for more would break every stored
// policy the day an admin first writes a limit; the run is capped and told
// so, in composer.Clamp's own idiom ("resources capped to operator
// maximum"). An authorized caller at a bound therefore earns no
// authz.denied row — nothing was denied.
//
// ENFORCED AT DISPATCH, in ONE place (runs_dispatch.go, beside the ceiling
// deny re-assertion), folded together with the deployment's own
// storage.ephemeral.max_disk_mib ceiling — never on the create path, whose
// resourceLimitsToRunner is a pure mapper with neither the ceiling nor the
// site config in scope. Assigned members only; operators are exempt.
//
// WHETHER THE CAP BINDS depends on the substrate: it is a request the runner
// makes of Kubernetes or Docker, and Docker's overlay2 does not enforce a
// size at all. Render it through StorageEnforcement and never claim a cap
// the substrate does not keep.
MaxEphemeralDiskMiB int `json:"max_ephemeral_disk_mib,omitempty"`
// MaxDriveSizeMiB caps how large a USER DRIVE may be for a member under this
// profile. 0 is unlimited. The governance twin of DenyUserDrive: that field
// answers "may this principal persist anything at all", this one answers
// "how much" — and a profile can carry either without the other.
//
// PER PRINCIPAL, and that is an open question the shape argument has to
// name: a drive today is one tree belonging to one subject, so a ceiling on
// its size is a ceiling on that person. Team-shared drives (0.8) add a scope
// axis on the same row rather than a second field — a shared drive's ceiling
// is not the sum of its members' and must not be derived from one. Until
// then, "per principal" is the whole meaning.
//
// CLAMPED IN newResolvedDrive (user_drives_resolve.go), the one scope that
// holds BOTH facts — never at grant write, where the profile binding a
// subject is claims-resolved and unreadable from the row. It folds with the
// deployment's own storage.user_drive.max_size_mib in one min() expression,
// so launch, /me and POST /drives/preview cannot disagree about a drive's
// size.
MaxDriveSizeMiB int `json:"max_drive_size_mib,omitempty"`
}
GovernanceLimits carries the autonomy switches that BOUND A REQUEST rather than a policy, which is why they live here and not in RunPolicySpec.
The distinction is load-bearing. RunPolicySpec describes what a sandbox may reach once it is running, and every field of it is enforced OUTSIDE the sandbox by the proxy. The booleans instead refuse a run SHAPE before it exists, because each names a way to route AROUND the tool gate entirely, or (DenyUserDrive) a way for a run to leave state behind it:
- DenyTaskModeExec: task_mode=exec runs a bare command with no agent and no toolgate in the loop, so no tool_rules ceiling can bind it. A profile that wants supervised tool use has to be able to say "not through that door".
- DenyInteractive: an interactive run REFUSES tool_approvals=hold by design (a human at the attach pane is the supervision), so a profile cannot express "supervised" through tool_rules on that lane either. This is the lever built for it.
- DenyUserDrive: a user drive is a tree that OUTLIVES the run, so a member who may mount one can persist anything the sandbox produced past the sandbox's own lifetime. No tool_rules ceiling describes that, because the escape is the storage, not the tool.
- MaxConcurrentRuns is the odd one out — a QUOTA, not a door. It bounds how many runs one member holds at once rather than what any single run may be, which is why its enforcement site answers 422 with no authz.denied while the booleans answer 403 with one.
- MaxEphemeralDiskMiB and MaxDriveSizeMiB are the two SIZES (0.7.2), and they are neither doors nor quotas: they CLAMP. A run or a drive at the bound is capped and told so, never refused — `disk_mib` is authored on policies, so a 422 would break every stored policy the day a limit is first written. Their enforcement sites are the two places that hold both the profile and the deployment ceiling: dispatch for the scratch size, newResolvedDrive for the drive.
A CLOSED struct with `omitempty` on every field, not a map: the set is small, complete, and validated by the Go type itself, so migration 0052 puts no CHECK on the limits column at all (the 0042 doctrine — one closed Go definition, validated at the write boundary, zero DDL for the next member). EVERY zero value means "unrestricted", so a profile that omits limits behaves exactly as one written before this struct had fields — the absent-row back-compat rule applied one level down.
type GovernanceProfile ¶ added in v0.7.0
type GovernanceProfile struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Ceiling RunPolicySpec `json:"ceiling"`
Limits GovernanceLimits `json:"limits"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by,omitempty"`
}
GovernanceProfile is one named, assignable ceiling (migration 0052's governance_profiles row).
Ceiling is a full RunPolicySpec and is REPLACEMENT semantics, not composition: an assigned profile IS the principal's ceiling, and a principal with no assignment falls through to the deployment's Config.DefaultPolicy byte for byte. Composing the two would mean folding them through composer.Clamp, which is NOT a lattice meet (it drops workspace_mounts unconditionally, is order-dependent through llm_inspection, and defaults unnamed tools to hold) — so a "composed" ceiling would silently lose fields and could not express an autonomous profile at all.
Name is the UNIQUE human handle: what an admin assigns by, what the console lists, and the last tie-break in the resolver's ORDER BY (so LIMIT 1 is deterministic even when priority ties).
type GrantKind ¶
type GrantKind string
GrantKind enumerates broker-mintable credential kinds.
const ( GrantGitHubToken GrantKind = "github_token" GrantCloudSTS GrantKind = "cloud_sts" // HARD-REQUIRES SPIRE identity provider GrantAPIKey GrantKind = "api_key" // proxy-side injection only // GrantGitPAT returns a STORED Personal Access Token VALUE to the git // credential helper (username/password) for a matched non-GitHub git host // (Azure DevOps / GitLab). This is the OPPOSITE of api_key: git-over-HTTPS to // those hosts is an opaque CONNECT tunnel the proxy cannot inject Basic-auth // into, so the credential must reach git via the helper — like github_token. GrantGitPAT GrantKind = "git_pat" // GrantSSHKey materializes a RESIDENT, agent-readable SSH private key so a run // can clone git-over-SSH. It is a DOCUMENTED EXCEPTION to the no-resident- // secret invariant: git's SSH transport has NO credential-helper seam (git // credential.helper is HTTP-only), so neither the git_pat helper trick nor // api_key proxy-side injection can carry the key — it MUST land as a 0400 file // the ssh client reads. agent-run writes it just before the clone and wipes it // right after (see deploy/images/*/agent-run), so the readable window is the // clone only. Transport is SSH-over-443 (ssh.github.com / ssh.dev.azure.com) // through the wardyn-proxy CONNECT tunnel — no port-22 egress. Residual risk is // the same posture as WARDYN_GIT_HELPER_SECRET: code running AS the agent uid // can read the key during that window. See broker.mintSSHKey + threat model. GrantSSHKey GrantKind = "ssh_key" // GrantEnvSecret places a STORED SECRET's value into the sandbox environment // under an operator-named variable, at dispatch. It is the second DOCUMENTED // EXCEPTION to the no-resident-secret invariant, and the honest reason is // coverage, not impossibility: a PAT-authenticated CLI or REST tool reads a // *_TOKEN env var, and neither the git_pat helper seam (git-only) nor api_key // proxy-side injection (one host, one header) can reach it. See // docs/adoption/corp-network-onboarding-findings.md B1. // // It is UNLIKE every other kind in three ways an operator must weigh: // // - NOT BROKERED. There is no mint, no approval gate, no TTL and no JTI — // the value is resolved store->env at dispatch (api.resolveEnvSecretGrants, // the same seam resolveLLMInspectionSecrets uses) and the broker refuses // the kind outright (mintKind's ErrUnknownGrantKind). // - RESIDENT FOR THE WHOLE RUN. Unlike ssh_key's clone window, an env var // lives as long as the process tree does; anything running as the agent // uid can read /proc/self/environ. // - NO REVOCATION. The kill-switch cascade revokes minted credentials; a // value already in a process env is not one, so killing the run stops the // process but does not un-disclose the secret. // // It is mask-registered at dispatch, and ADMIN-ONLY by default: a member's // env_secret grant is dropped unless the operator opens // WARDYN_ALLOW_MEMBER_ENV_SECRET. Scope is {"name":"MY_TOKEN", // "secret_name":"stored-name"}. See threatmodel/THREAT-MODEL.md §5.1a. GrantEnvSecret GrantKind = "env_secret" )
type GrantSpec ¶
type GrantSpec struct {
Kind GrantKind `json:"kind"`
// Scope is kind-specific: for github_token {"repos":[...],"permissions":{...}},
// for api_key {"host":"...","header":"..."}, for git_pat
// {"host":"...","secret_name":"...","username":"<optional>"} — the stored
// secret_name's PAT value is returned to the git credential helper as the
// password for host, with username resolved by convention (ADO=pat,
// GitLab=oauth2) unless overridden — and for ssh_key
// {"host":"...","key_secret_ref":"...","username":"<optional, default git>",
// "known_hosts_secret_ref":"<optional>"} — the stored key_secret_ref's private
// key VALUE is returned to agent-run, which writes it to a 0400 file for the
// SSH-over-443 clone and wipes it after (see GrantSSHKey).
Scope json.RawMessage `json:"scope"`
// TTL of the minted credential. Max (and default) 1h.
TTLSeconds int `json:"ttl_seconds,omitempty"`
// RequiresApproval forces a human approval to mint (vs auto-mint on policy).
RequiresApproval bool `json:"requires_approval"`
}
GrantSpec is a credential scope description. The broker enforces the invariant: a minted credential's scope is exactly the approved scope — never wider (no scope-widening between request and mint).
type HomeTemplate ¶ added in v0.7.0
type HomeTemplate string
HomeTemplate names WHICH identity a drive's per-user home name is derived from. Closed; pinned against user_drives.home_template's CHECK.
const ( // HomeTemplateHash is `d-` + a truncated sha256 over the drive id and the // subject: deterministic, DNS-1123-safe for a PVC name, and it leaks no // identity into an object name an operator will read in `docker volume ls`. // The default, and the ONLY template a MANAGED backend may use — where // Wardyn is naming an object nothing else has an opinion about, a name // derived from a claim buys nothing and can collide (see the two claim // templates below); ValidateUserDrive refuses the others there. HomeTemplateHash HomeTemplate = "hash" // HomeTemplateSub uses the sign-in subject claim verbatim, lowercased. // SHARE backends only: it is not folded with the drive id, so one member's // two drives would be one object, and the lowercasing makes two subjects // differing only in case one home. HomeTemplateSub HomeTemplate = "sub" // HomeTemplateEmailLocal uses the part of the email claim before the "@" — // the usual shape of a corporate home directory (alice@corp -> alice). // SHARE backends only: the domain is dropped, so alice@corp.example and // alice@partner.example name ONE directory. On a share that directory was // minted by somebody else and the answer is a home_override on the // colliding person's allocation; on a managed backend Wardyn would be // minting the collision itself, so ValidateUserDrive refuses it there. // // THERE IS DELIBERATELY NO WHOLE-EMAIL TEMPLATE. An address carries an "@", // which driveHomeSegmentRe excludes and a DNS-1123 label could not hold // either, so such a template could only ever RESOLVE for a claim that was // not an address — a value that validates and then refuses every real // caller is a dead enum member, not an option. The corporate case it looked // like it served (a home directory named by the person's username) is // exactly this template; a home named anything else is a per-user // home_override on that person's grant. HomeTemplateEmailLocal HomeTemplate = "email_local" )
func (HomeTemplate) Valid ¶ added in v0.7.0
func (t HomeTemplate) Valid() bool
Valid reports whether t is one of the three templates.
type Integration ¶ added in v0.5.0
type Integration struct {
// ID is an operator-chosen stable slug, unique within SiteConfig.Integrations
// (this is a config sub-object inside the SiteConfig singleton, not its own
// table, so there is no generated uuid — the operator names it, and
// WorkspaceLLMCred.IntegrationRef / DefaultFor point at this ID).
ID string `json:"id"`
Name string `json:"name"`
// Kind is the ONE field that says what this connects to: a closed-set kind
// (ClosedIntegrationKinds) with bespoke behavior, or any other slug — a
// generic connection whose row carries its whole contract.
Kind string `json:"kind"`
// Disabled turns the integration off without deleting its configuration.
Disabled bool `json:"disabled,omitempty"`
// Secrets are the required secrets, each with its delivery. Refs/names only.
Secrets []IntegrationSecret `json:"secrets,omitempty"`
// Egress is WHERE THE SYSTEM LIVES: the host entries a run granted this
// integration may reach. This is the reason a host is on a run's egress
// allowlist, instead of being hand-listed in every workspace that needs it.
// Entries use the policy allowlist's own syntax (proxy.ValidDomainEntry):
// an exact host, a leading-"*." wildcard, either optionally ":port".
//
// A WILDCARD OPENS THE PATH BUT NEVER CARRIES THE CREDENTIAL: proxy-side
// injection requires an EXACT allowlist entry (Policy.AllowedExactHost), so
// a secret can never leak to a wildcard-matched host. An integration with a
// proxy_header-delivered secret is therefore rejected at write time if any
// of its egress entries is a wildcard or carries a port — the alternative
// is a row that looks credentialed and silently isn't.
Egress []string `json:"egress,omitempty"`
// Config is non-secret, kind-validated configuration. Closed kinds accept
// only their known keys (bedrock ⇒ region/model/auth_lane, github_app ⇒
// app_id/installation_id/host, anthropic_subscription ⇒ lane — an unknown
// key 400s by name at write); generic kinds take any string keys.
Config map[string]any `json:"config,omitempty"`
// Docs optionally points at whatever documents this system, so whoever
// comes after the operator who added it can find out what it is.
Docs string `json:"docs,omitempty"`
// DisabledCapabilities lists capability names this integration does NOT
// support, so callers don't need a hardcoded per-provider capability matrix.
DisabledCapabilities []string `json:"disabled_capabilities,omitempty"`
// DefaultFor lists what this integration is the operator-chosen default
// for: "agent_runs" (a new run picks this absent an explicit choice) and/or
// "wardyn_features" (Wardyn's own internal usage, e.g. scan/record runs).
DefaultFor []string `json:"default_for,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// contains filtered or unexported fields
}
Integration is one operator-configured external connection: a base component — required secrets (each with its delivery), required egress, non-secret config, an optional verification probe — extended by kind. Like every other SiteConfig-doctrine type, Secrets holds secret NAMES (refs), never VALUES — the broker/proxy resolve the named secret at dispatch/ injection time.
STORAGE COMPATIBILITY: UnmarshalJSON also accepts the pre-base-component shape ({category, type, hosts, header, format, credentials, config}) and folds it into this one at read time — one-way, write-new (marshal emits only this shape). See foldLegacyIntegration.
func (Integration) CredentialsMap ¶ added in v0.5.0
func (in Integration) CredentialsMap() map[string]string
CredentialsMap flattens Secrets into a role → secret_name map. ONE consumer left, and it wants exactly this shape: the integration.delete audit payload, recording which credential REFS a deleted row named (the operator's secrets themselves are not deleted). Never a read path — anything that ACTS on a secret reads the Secrets rows, because only they carry the delivery that says how it reaches a run.
func (Integration) HeaderSecret ¶ added in v0.5.0
func (in Integration) HeaderSecret() (secretName, header, format string, ok bool)
HeaderSecret returns the first proxy_header-delivered secret row — the generic proxy-injected credential lane — as the (secretName, header, format) triple injection consumes. An empty stored format reads as "%s" (the raw secret IS the header value; injectionRuleFromScope would otherwise default "" to "Bearer %s", which is wrong for every custom credential header). ok=false when no secret is proxy_header-delivered.
func (Integration) RoleSecret ¶ added in v0.5.0
func (in Integration) RoleSecret(role string) string
RoleSecret returns the secret NAME stored for role ("" when the role is not configured).
func (*Integration) UnmarshalJSON ¶ added in v0.5.0
func (in *Integration) UnmarshalJSON(b []byte) error
UnmarshalJSON accepts BOTH the base-component shape (discriminated by its "kind" key) and the legacy {category, type, hosts, header, credentials} shape, folding the latter forward (foldLegacyIntegration). This is the ONE read-time migration chokepoint: every decode path — the store's SiteConfig document, a saved wire body — folds here, and marshal emits only the new shape (one-way, write-new). Precedent: foldLegacyArtifactOverrides (internal/api/site_config.go).
type IntegrationDelivery ¶ added in v0.5.0
type IntegrationDelivery struct {
Mode string `json:"mode"` // proxy_header | resident_file | resident_env
// Header/Format apply to proxy_header: the HTTP field name the proxy adds,
// and the value template it wraps the secret in (exactly one %s; empty
// means the raw secret IS the value, i.e. "%s").
Header string `json:"header,omitempty"`
Format string `json:"format,omitempty"`
// Path applies to resident_file: the in-sandbox path the secret lands at.
Path string `json:"path,omitempty"`
// Var applies to resident_env: the environment variable name.
Var string `json:"var,omitempty"`
}
IntegrationDelivery says how ONE secret reaches the run. Exactly one mode; the mode decides which of the other fields apply.
func AIKeyDelivery ¶ added in v0.5.0
func AIKeyDelivery(kind string) *IntegrationDelivery
AIKeyDelivery is the proxy-header injection convention an AI api-key kind's "api_key" secret rides — the same convention the harness catalog's Gateway rows encode (internal/api/harness.go), recorded here so a folded/derived row states the delivery that actually happens. nil for a kind whose api_key has no proxy-header lane.
type IntegrationList ¶ added in v0.5.0
type IntegrationList []Integration
IntegrationList is SiteConfig's integrations slice with the read-time migration applied at decode: each row folds individually (see Integration.UnmarshalJSON), and rows folded from the legacy artifact_mirror/host_proxy categories are DROPPED — they are network topology, homed under Corporate network, and this surface stops carrying them. A NEW-shape row is never dropped, whatever its kind slug.
func (*IntegrationList) UnmarshalJSON ¶ added in v0.5.0
func (l *IntegrationList) UnmarshalJSON(b []byte) error
UnmarshalJSON decodes the rows then drops legacy topology rows.
type IntegrationSecret ¶ added in v0.5.0
type IntegrationSecret struct {
Role string `json:"role"`
SecretName string `json:"secret_name"`
Delivery *IntegrationDelivery `json:"delivery,omitempty"`
}
IntegrationSecret is one required secret of an integration: a role (what it is to this system), a ref into the secret store (a NAME, never a value), and how it is delivered. A nil Delivery is allowed on CLOSED kinds only and means the kind's own bespoke transport delivers it (the brokered github_app halves, git_host clone credentials) — hand-written per provider, not declarable here.
type InternalHost ¶ added in v0.7.0
type InternalHost struct {
// HostSuffix matches a request host by label suffix: HostSuffix itself, or
// any host ending in "."+HostSuffix (never a substring/mid-label match).
HostSuffix string `json:"host_suffix"`
// CIDRs scopes the lift to these ranges only. Each must lie entirely inside
// RFC1918, fc00::/7, or 100.64.0.0/10 (ipguard.Liftable) — never loopback/
// link-local/metadata/multicast/NAT64. Empty means the lift applies to the
// full Liftable set for a matching host.
CIDRs []string `json:"cidrs,omitempty"`
}
InternalHost is one SiteConfig.InternalHosts entry — see that field's doc.
type LLMInspectionSpec ¶
type LLMInspectionSpec struct {
// Mode is "off" (default), "alert" (scan + audit, forward unchanged), or
// "block" (a qualifying finding refuses the request). "" == "off".
Mode string `json:"mode"`
// WorkspaceSecretNames are operator-declared secret NAMES, resolved against
// the at-rest secret store — the field an operator/admin actually AUTHORS
// (on a stored policy or an inline_policy). This is the storable form of the
// detection corpus: a name is not sensitive the way a value is, so it may
// freely appear in a stored policy row, a policy read, or a compose/profile
// proposal returned to a caller. Resolved to WorkspaceSecretValues ONLY at
// dispatch (resolveLLMInspectionSecrets, internal/api/runs_dispatch.go), in
// memory, on the per-dispatch policy copy handed to the proxy sidecar.
WorkspaceSecretNames []string `json:"workspace_secret_names,omitempty"`
// WorkspaceSecretValues are the RESOLVED secret VALUES (e.g. the contents of
// a mounted .env) the run must not leak into a prompt — the v1 detection
// corpus the proxy sidecar actually matches against. NOT an authoring
// field: validatePolicySpec refuses a non-empty value here on every policy
// write (stored, inline, or WARDYN_DEFAULT_POLICY) — author
// WorkspaceSecretNames instead. Populated ONLY by dispatch, in memory, on
// the ephemeral copy of the spec sent to the proxy; every other copy (the
// stored row, a policy read DTO, a compose/profile proposal, the
// run.policy.effective audit event) carries names only, values redacted to
// a count. NEVER logged. Values shorter than the masking floor are ignored.
WorkspaceSecretValues []string `json:"workspace_secret_values,omitempty"`
// DetectSecrets enables the known-secret detector (exact match against the
// resolved WorkspaceSecretValues corpus). At least one Detect* must be true
// when Mode != off.
DetectSecrets bool `json:"detect_secrets,omitempty"`
// DetectSecretPatterns enables the regex catalog of well-known secret FORMATS
// (AWS/GitHub/Slack/Google keys, PEM private keys, JWTs, Stripe). Higher
// precision than entropy but can false-positive on example/test keys in code.
DetectSecretPatterns bool `json:"detect_secret_patterns,omitempty"`
// DetectEntropy enables the Shannon-entropy detector (high FP in code; medium
// severity so a strict block_min_severity can exclude it). Off by default.
DetectEntropy bool `json:"detect_entropy,omitempty"`
// DetectPII enables the regex/Luhn PII detector (best-effort, high false-
// negative recall; a visibility signal, never a control). Off by default.
DetectPII bool `json:"detect_pii,omitempty"`
// DetectorSidecarURL, when set, adds an out-of-process detection sidecar
// (e.g. a Presidio / Protect-AI LLM-Guard wrapper) the proxy POSTs each span
// to. Trusted operator config (not agent-chosen). A sidecar error/timeout/non-200
// is treated as a scanner error and respects on_scanner_error like any other:
// fail-OPEN by default (the request still flows), fail-CLOSED (the request is
// blocked in block mode) when on_scanner_error=block — so block mode with the
// sidecar as the SOLE detector DOES guarantee a block on sidecar failure.
DetectorSidecarURL string `json:"detector_sidecar_url,omitempty"`
// ClassifiedMarkers are operator-defined literal markers (e.g. "INTERNAL ONLY",
// "CONFIDENTIAL//NOFORN") whose presence in outbound content flags a
// classified-content leak (the "proprietary content shouldn't leave" sense of
// the walled garden). Case-insensitive substring match; category "classified".
ClassifiedMarkers []string `json:"classified_markers,omitempty"`
// ScanAttachments opts into decoding+scanning base64 image/document attachment
// bytes in a prompt (off by default — binary, large, high-FP).
ScanAttachments bool `json:"scan_attachments,omitempty"`
// InspectForwardEgress extends inspection from the LLM routes to the GENERIC
// plaintext-HTTP forward path, so a custom (non-LLM) HTTP connector's POST/PUT
// body is scanned too. Off by default. (HTTPS connectors tunnel via opaque
// CONNECT and remain uninspected unless MITM'd — see threatmodel §5.1a.)
InspectForwardEgress bool `json:"inspect_forward_egress,omitempty"`
// MaxScanBytes caps the size of a single extracted span scanned; 0 => a
// built-in default. A larger span is skipped (fail-open) and recorded.
MaxScanBytes int `json:"max_scan_bytes,omitempty"`
// OnScannerError selects behavior when the scanner ERRORS (e.g. an
// unparseable body): "pass" (default, fail-open) or "block".
OnScannerError string `json:"on_scanner_error,omitempty"`
// RequireInspectableLLM, when true, refuses to schedule a run whose resolved
// LLM transport is opaque (subscription-OAuth/Bedrock CONNECT) and therefore
// cannot be inspected — fail-closed, like MinConfinementClass. Default false
// only WARNS (the common subscription user is not punished).
RequireInspectableLLM bool `json:"require_inspectable_llm,omitempty"`
// InterceptTLS opts the run into TLS-MITM of opaque CONNECT tunnels to known
// LLM hosts (Anthropic/OpenAI), making the subscription-OAuth path inspectable.
// The control plane provisions a per-run CA: the PRIVATE key goes only to the
// proxy sidecar; the sandbox trusts only the CA's PUBLIC cert. Adds a CA trust
// dependency inside the sandbox — see threatmodel §5.1a. Off by default.
InterceptTLS bool `json:"intercept_tls,omitempty"`
// BlockMinSeverity is the minimum finding severity that triggers a block in
// "block" mode ("low"|"medium"|"high"|"critical"; "" => low).
BlockMinSeverity string `json:"block_min_severity,omitempty"`
}
LLMInspectionSpec configures optional outbound LLM prompt inspection for a run. The zero value (or a nil *LLMInspectionSpec on the policy) means OFF.
type ResolvedDrive ¶ added in v0.7.0
type ResolvedDrive struct {
Drive UserDrive
Grant UserDriveGrant
Tier CapabilitySubjectType
// HomeName is the per-user segment: the subdirectory of a share, or the
// suffix of a managed object's name.
HomeName string
// SubjectHash fingerprints the principal HomeName was derived FROM
// (DriveSubjectHash), which the home itself cannot answer for: a managed
// object's name carries the home and nothing else, so two principals whose
// template collapses onto one home produce one object name and no way to
// tell them apart. Carried here so the runner can stamp it on the object it
// allocates and refuse one stamped for somebody else.
SubjectHash string `json:"subject_hash,omitempty"`
// ObjectName is what the runner asks the substrate for — a volume name, a
// PVC name, or an absolute host path.
ObjectName string
SizeMiB int
Writable bool
Enforcement StorageEnforcement
// Paused is set when the grant that WON is disabled: Drive and Grant are
// that row, the size and mode folds still ran, nothing is derived (no
// HomeName, no ObjectName) and nothing may be mounted. A paused row wins
// its tier rather than falling through to the wider row beneath it
// (DESIGN §2.2), so an admin turning one off cannot silently hand that
// member the everyone drive instead.
Paused bool
}
ResolvedDrive is THE answer for one principal: the winning drive, the grant that won, the tier it won at, and everything derived from the pair that a runner or a preview needs. Built once, by the API resolver, so no consumer re-derives a home name or an override.
Writable is already folded (grant override over drive default) but NOT yet narrowed by the run request — a request may only narrow, and that fold belongs to the run-create seam that has the request in hand.
NO json tags: this type crosses no wire. The preview and /me each compose their own response struct from it, deliberately — Grant is a whole admin grant row, and a struct that looks marshal-ready is a struct somebody marshals.
type ResolvedInjection ¶ added in v0.4.4
type ResolvedInjection struct {
Host string `json:"host"`
Header string `json:"header"`
Value string `json:"value"`
JTI string `json:"jti"`
ExpiresAt int64 `json:"expires_at,omitempty"`
// Organisation is the Azure DevOps organisation a per-person Azure DevOps
// credential was dispatched for, on that lane's resolves only (empty on
// every other). It is informational: the proxy does not read it. The
// proxy's REST gate pins the organisation from the dispatch-time ADOGrants
// in its own configuration (proxy.ADOGrantConfig), never from a resolve.
Organisation string `json:"organisation,omitempty"`
// Capabilities is the same lane's GRANTED capability set, in the
// internal/adoscope vocabulary. The gate does NOT hold requests to it: it
// reads the dispatch-time ADOGrants. The proxy reads it in one place only,
// on a capability ask's resolve (ado_hold.go), to confirm the capability it
// asked for came back granted. Empty on every other lane.
Capabilities []string `json:"capabilities,omitempty"`
}
ResolvedInjection is the ONE wire contract of GET /api/v1/internal/injection/{grantID}: the control plane's injection-resolve result, carrying the header name and the FORMATTED secret value (formatting applied server-side). ExpiresAt (unix ms, 0 = never) marks a rotating credential the proxy must re-resolve before it lapses (the subscription OAuth token); a static api-key grant leaves it 0.
It lives here, in the neutral package both sides already import, because the api server (encoder) and the wardyn-proxy (decoder) previously each kept a hand-copied struct. Nothing pinned the two together, so a one-sided rename of expires_at would have silently made the proxy read 0 = "static credential, never re-resolve" and let a live OAuth token lapse mid-run. Sharing the type makes the compiler, not a parity test, the thing that keeps them equal.
type ResourceLimits ¶
type ResourceLimits struct {
CPUMillis int `json:"cpu_millis,omitempty"` // milli-CPU; 2000 = 2 vCPU
MemoryMiB int `json:"memory_mib,omitempty"` // hard memory cap in MiB
PidsLimit int `json:"pids_limit,omitempty"` // max processes/threads (fork-bomb guard)
DiskMiB int `json:"disk_mib,omitempty"` // writable storage cap in MiB (best-effort)
}
ResourceLimits caps a sandbox's resource consumption. A ZERO field means "use the platform default": the dispatch path fills conservative defaults (e.g. 2000m CPU, 4096 MiB, 512 PIDs) so even a policy that sets no limits still runs capped. DiskMiB is best-effort and depends on the storage driver supporting a per-container quota (fail-closed/warn when a cap is demanded but unsupported).
type RoleMapping ¶ added in v0.7.0
type RoleMapping struct {
ID uuid.UUID `json:"id"`
Value string `json:"value"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
}
RoleMapping is one console-managed (Getting Started -> People) row of migration 0051's role_mappings table: "value maps to role". This is the STORE'S wire type, carrying id/timestamps/provenance — distinct on purpose from internal/auth/oidc's own RoleMapping (just Value/Role), which stays dependency-free of this package (see that type's doc comment) the same way oidc.SessionRevocations keeps oidc dependency-free of store; the API layer converts between the two, mirroring however SessionRevocations bridges store -> oidc today.
Value is expected already canonical (trimmed, lowercased, ASCII) by the API write boundary that owns writes to this table — see the migration comment.
type RunLayout ¶ added in v0.5.0
type RunLayout struct {
Preset string `json:"preset"`
Layout []RunLayoutWidget `json:"layout"`
UpdatedAt time.Time `json:"updated_at"`
}
RunLayout is a human's saved widget arrangement for the run-detail cockpit (GET/PUT /api/v1/me/run-layout), one row per (principal, preset) — see migration 0037_ui_layouts.sql and internal/api/ui_layout.go's doc comment for why this is a server row and not ui/src/app/lib/storage.ts's localStorage. Principal deliberately does not appear here: it is the row's SCOPE (how internal/store looks a layout up), never a value the API echoes back or a caller supplies — internal/api/ui_layout.go scopes every read and write by principalFromRequest(r) alone.
type RunLayoutWidget ¶ added in v0.5.0
type RunLayoutWidget struct {
Widget string `json:"widget"`
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
}
RunLayoutWidget places one evidence widget on the run cockpit's grid. Widget is validated server-side against a closed id set (internal/api/ui_layout.go's runLayoutWidgetIDs) before a layout is ever stored — an id no build renders would otherwise be a widget that silently vanishes on restore, with nothing to explain why.
type RunPolicy ¶
type RunPolicy struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Spec RunPolicySpec `json:"spec"`
}
RunPolicy is the declarative policy attached to runs: egress allowlist, approval rules, and the maximum credential scopes a run may be granted. Workspace-local configuration may only NARROW a policy, never widen it.
type RunPolicySpec ¶
type RunPolicySpec struct {
// AllowedDomains is the L2 egress allowlist (exact hosts or "*." wildcards).
AllowedDomains []string `json:"allowed_domains"`
// DeniedDomains always wins over AllowedDomains.
DeniedDomains []string `json:"denied_domains,omitempty"`
// AllowAllEgress switches L2 egress from default-deny (allowlist only) to
// "allow all (deny-list only)" mode: when true the proxy allows ANY
// non-denied PUBLIC host, and AllowedDomains may be empty. denied_domains
// STILL wins. The SSRF/private-IP guard (VetHost/isBlockedIP) is unaffected
// — allow-all is public hosts only; a host that resolves to a private/
// loopback/link-local/metadata range is still unconditionally denied. And
// credential injection STILL requires an EXACT allowlist entry (AllowedExactHost),
// so allow-all never widens where a secret may be injected — a secret must
// never leak to an arbitrary host. first_use_approval is inert under allow-all.
AllowAllEgress bool `json:"allow_all_egress,omitempty"`
// FirstUseApproval controls how an unknown domain is handled: always_deny
// (hard-deny, no approval), deny_with_review (raise approval, deny now, retry
// passes once approved), or wait_for_review (raise approval and hold the
// connection until decided). Accepts the legacy boolean on the wire. Inert
// under allow-all.
FirstUseApproval FirstUseMode `json:"first_use_approval"`
// FirstUseHoldSeconds bounds how long a wait_for_review connection is HELD
// open awaiting a decision before the proxy refuses it. 0/absent keeps the
// built-in 30s default (back-compat); a positive value overrides it. Only
// wait_for_review holds; the other first-use modes never hold. See
// internal/egress/proxy configureHold.
FirstUseHoldSeconds int `json:"first_use_hold_seconds,omitempty"`
// MaxHolds caps concurrent wait_for_review holds (one held goroutine per
// held connection). 0/absent keeps the built-in 16 default; a positive value
// overrides it. The (N+1)th concurrent held connection fails fast rather than
// consuming an unbounded goroutine.
MaxHolds int `json:"max_holds,omitempty"`
// AllowedMethods optionally restricts HTTP methods (empty = all).
AllowedMethods []string `json:"allowed_methods,omitempty"`
// MinConfinementClass refuses to launch below this class.
MinConfinementClass ConfinementClass `json:"min_confinement_class"`
// EligibleGrants is the ceiling of credential scopes a run may request.
EligibleGrants []GrantSpec `json:"eligible_grants,omitempty"`
// AutoStopAfter stops idle sandboxes after this many seconds. 0 — which is
// also what an ABSENT field coalesces to — means never reaped, identical to
// an explicit negative (see internal/lifecycle: the reaper skips <= 0). A
// negative value exists only to state never-reap intent explicitly.
AutoStopAfterSec int `json:"auto_stop_after_sec,omitempty"`
// WorkspaceMounts are OPERATOR/ADMIN-controlled host bind mounts injected
// into the sandbox (e.g. a host repo at ~/work that edits persist to). They
// are admin-gated: a mount may be authored on a stored policy (via the
// admin-gated policy CRUD) OR INLINE on a create-run request by an admin /
// SSO-gated human operator (createRunRequest.InlinePolicy) — both flow
// through this same RunPolicySpec. A mount is NEVER chosen by the in-sandbox
// agent: the agent-run entrypoint has no access to either authoring surface,
// so a prompt-injected agent or a malicious in-sandbox actor can never pick
// what host paths get mounted. validatePolicySpec runs runner.ValidateMount
// (the same deny-list the docker driver enforces: absolute cleaned Source not
// under a dangerous host path; Target under an allowed in-container prefix;
// default read-only) so a bad mount is rejected at policy-write / inline-
// validate time (HTTP 400) AND again defense-in-depth in the driver at
// sandbox-create time.
WorkspaceMounts []WorkspaceMount `json:"workspace_mounts,omitempty"`
// WorkspaceRepos are additional git repos attached to a run, paralleling
// WorkspaceMounts for git-cloned (rather than bind-mounted) sources — the
// multi-workspace run model. Same admin/inline authoring
// surface and trust boundary as WorkspaceMounts: never agent-chosen.
// validatePolicySpec validates each set Target via runner.ValidateTarget and
// enforces a unique-target invariant across ALL WorkspaceMounts +
// WorkspaceRepos dests, so a clone can never land on a bind target (or
// shadow another repo's checkout). Rejecting a repo whose Source is not an
// ONBOARDED workspace is a later, security-critical wave — this
// type only adds the structural shape.
WorkspaceRepos []WorkspaceRepo `json:"workspace_repos,omitempty"`
// LLMInspection optionally enables OUTBOUND content inspection at the proxy
// for brokered LLM routes (the "inadvertent-leak guardrail"). Nil/omitted =>
// OFF (no scanning) — the safe default, mirroring WorkspaceMount.ReadOnly's
// pointer-means-unset idiom. It is a guardrail + visibility layer, NOT
// exfiltration prevention (see internal/contentscan + threatmodel §5.1).
LLMInspection *LLMInspectionSpec `json:"llm_inspection,omitempty"`
// UIApps declares the in-sandbox loopback HTTP apps the UI gateway may relay
// to a browser (a code editor, a dev server). OPERATOR-AUTHORED, the same
// trust boundary as WorkspaceMounts: an app is a NAME, a loopback PORT and a
// PATH — never a command string, so a policy can never choose what runs
// inside the sandbox (the launcher is a convention,
// /usr/local/bin/wardyn-ui-<name>). The gateway serves ONLY a declared port
// and refuses anything else naming this field; `ssh -L` stays the
// undeclared-port escape hatch. Empty (the default) = this run has no UI
// apps, which is what every policy authored before this field said.
UIApps []UIApp `json:"ui_apps,omitempty"`
// Resources caps sandbox CPU/memory/PIDs/disk. Nil, or a zero field, means
// "use the platform default": the dispatch path fills conservative defaults so
// EVERY run is capped even when a policy sets nothing. These are the basic
// multi-tenant safety controls that let a FLEET of independent agents coexist —
// without them one runaway or prompt-injected agent can OOM-kill the host,
// fork-bomb the host PID space, or fill host storage and take down sibling runs.
Resources *ResourceLimits `json:"resources,omitempty"`
// ToolRules narrows an autonomous run's tool use from a BINARY to a policy.
//
// `tool_approvals` alone has exactly two settings and no middle: `auto` runs
// every tool with no gate at all, and `hold` routes EVERY gated call to a
// human. On a real task the first is more autonomy than an operator wants and
// the second is more interruptions than a human answers — which is how
// approval fatigue starts, and an operator who tires of approving picks
// `auto` for everything.
//
// A rule names a TOOL and an effect: allow it outright, hold it for a human,
// or deny it outright. So "read freely, ask before shell, never fetch the
// web" becomes expressible, and the human is asked only about the calls that
// warrant asking.
//
// OPERATOR-AUTHORED and evaluated OUTSIDE the sandbox — proxy-side, on the
// same brokered route the approval already travels — so a compromised agent
// cannot rewrite its own rules. Empty (the default) preserves today's exact
// behaviour: every gated call under `hold` goes to a human.
//
// It narrows; it never widens. A rule is consulted ONLY for a run that is
// already in `hold`, so adding one cannot turn a supervised run autonomous —
// the worst a bad rule can do is ask a human more often, or refuse a call the
// agent wanted.
ToolRules []ToolRule `json:"tool_rules,omitempty"`
// GitPushAnyBranch turns OFF branch-namespace confinement for THIS run's
// brokered pushes — on the GitHub App lane, where the confinement is on by
// default, and on the git_pat lane wherever the operator opted that one in
// (WARDYN_GIT_PAT_BROKER_ENFORCE_BRANCH_NS, off by default). One field, both
// brokers: the reason a human-driven run names its own branches does not
// change with the forge. By default the proxy git-broker forwards a push
// only when every ref it updates lives under refs/heads/wardyn/<run-id>/
// (ARCHITECTURE.md "Git egress"), so an agent cannot rewrite main. That is
// the right default for an autonomous run and the wrong one for a sandbox
// a HUMAN drives through an external tool (an IDE or agent workbench over
// the SSH gateway) that names its own branches — the push is refused and
// the tool cannot explain why.
//
// true = forward pushes to any branch the granted token may write. The
// audit stream distinguishes the posture per push (rule_source
// brokered:git:branch-ns-off) and the grant's own GitHub ruleset still
// bounds what the token can touch (docs/POLICIES.md "Bound the token
// itself"). Operator-authored, never agent-settable; false/absent keeps
// today's behaviour exactly.
//
// ponytail: whole-namespace off, not a per-run allowed-prefix list — add a
// prefix list when someone needs an external tool AND confinement at once.
GitPushAnyBranch bool `json:"git_push_any_branch,omitempty"`
}
func (RunPolicySpec) Clone ¶ added in v0.3.1
func (s RunPolicySpec) Clone() RunPolicySpec
Clone returns a deep copy: every slice/pointer field is reallocated, so the copy shares NO backing array with the receiver.
A plain `spec := other` is a SHALLOW copy — the struct is duplicated but each slice header still points at the original's backing array. That made the process-global default policy aliasable: a per-run `append` to AllowedDomains wrote into the shared spare capacity, so two concurrent create-runs raced on the same element (one run's egress domain silently replacing another's in the allowlist handed to its proxy), and any in-place mutation leaked into every later run. Callers that derive a per-run/per-request spec from a shared one MUST Clone first.
type RunState ¶
type RunState string
RunState is the AgentRun lifecycle state machine.
const ( RunPending RunState = "PENDING" RunStarting RunState = "STARTING" RunRunning RunState = "RUNNING" // RunWaiting is a RESERVED, not-yet-produced state: no backend path // transitions a run into WAITING_FOR_CONFIRMATION today. It is the planned // human-in-the-loop gate — a run that pauses for an operator to confirm a // risky action mid-flight — and is deliberately kept wired end to end (UI // attention badge in App.tsx/runs.tsx, primitives.tsx label, SDK alias, e2e // fixtures) so the display + attention semantics are proven before the // producer lands with the approval-gated-run feature. Reserved, not dead: // removing it would strip a designed seam the UI already renders. RunWaiting RunState = "WAITING_FOR_CONFIRMATION" RunStopped RunState = "STOPPED" RunArchived RunState = "ARCHIVED" RunFailed RunState = "FAILED" RunKilled RunState = "KILLED" // RunCompleted is the terminal success state: the agent process exited 0. // A non-zero exit transitions the run to RunFailed instead. The completion // watcher (see internal/api/runs.go dispatch) sets this from RunRunning. RunCompleted RunState = "COMPLETED" )
func (RunState) IsTerminal ¶ added in v0.4.4
IsTerminal reports whether the run has already ended — it can no longer be killed, stopped or dispatched. This is the SINGLE source of the terminal set: the API's terminal guards, the CLI's `run --wait` exit codes and the e2e polls all read it here (pkg/client aliases RunState, so SDK consumers get it too), because a hand-copied set drifts — omitting COMPLETED once already shipped a live Kill button on finished runs. The UI keeps the only other copy (ui/src/app/lib/types/runs.ts); TestTerminalRunStates_UIParity fails if the two ever disagree.
type SSHPublicKey ¶ added in v0.5.0
type SSHPublicKey struct {
Fingerprint string `json:"fingerprint"`
Principal string `json:"principal"`
Name string `json:"name"`
PublicKey string `json:"public_key"` // authorized_keys line; never a secret
Role string `json:"role"`
RoleCheckedAt *time.Time `json:"role_checked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
SSHPublicKey is a human's registered public key for the SSH gateway (`GET`/`POST`/`DELETE /api/v1/me/ssh-keys`), distinct from GrantSpec's "ssh_key" grant kind (a RESIDENT private key materialized for git-over-SSH cloning — see GrantSSHKey). This type is the gateway's own trust root: a human self-registers a public key against their principal, and the gateway authenticates an incoming SSH connection ONLY against rows here, then authorizes it owner-OR-admin (AgentRun.CreatedBy == Principal, or Role == oidc.RoleAdmin — see internal/api/sshgateway.go).
Fingerprint is the SHA256 form (ssh.FingerprintSHA256: "SHA256:<base64>"), computed SERVER-SIDE from the parsed key — never client-supplied — so it is both the natural primary key (a key's fingerprint is intrinsic to its bytes; two rows can never disagree about which key they name) and the value shown to a human for "verify on first connect".
Role is the registering session's OWN role (oidc.RoleAdmin/RoleMember), stamped at registration by handleAddSSHKey (migration 0043) and REFRESHED on every OIDC login for the authenticating principal's keys (migration 0046). It is a BOUNDED-STALE stamp, not a live check — SSH carries no session for requireOperator to read — so a demoted admin's key keeps its override only until RoleCheckedAt exceeds WARDYN_SSH_ROLE_TTL, or until the key is deleted/re-registered (docs/SSH.md §Bounds).
RoleCheckedAt is when Role was last stamped — at registration, or at any later OIDC login (migration 0046). nil means "never refreshed since upgrading to 0046" — a pre-migration row, or a key registered before an OIDC-configured deployment's first login for that principal — and sshAuth treats nil as infinitely stale, never as fresh.
type SiteConfig ¶
type SiteConfig struct {
// UpstreamProxySecretRef names a secret holding the corporate upstream proxy
// URL (optionally with embedded user:pass), or "" when no upstream proxy is
// configured. Mutually exclusive in PRACTICE with UpstreamProxyURL (either
// may be set; UpstreamProxyURL wins when both are — see
// resolveUpstreamProxyURL) but not rejected as a validation error, since an
// operator migrating from one to the other may round-trip both briefly.
UpstreamProxySecretRef string `json:"upstream_proxy_secret_ref,omitempty"`
// UpstreamProxyURL is the corporate upstream proxy URL written IN THE CLEAR
// (http only — see resolveUpstreamProxyURL). A proxy URL is topology, not a
// credential, and forcing every operator through the write-only secret store
// means a mistyped URL can never be read back to debug. It MUST NOT embed a
// userinfo (user:pass@) — validateSiteConfig rejects that at write time with
// a 400 telling the caller to use UpstreamProxySecretRef instead, which
// exists precisely for a proxy that DOES need an embedded credential.
UpstreamProxyURL string `json:"upstream_proxy_url,omitempty"`
// UpstreamProxyNoProxy is the corporate upstream proxy's BYPASS list: the
// destinations wardyn-proxy must dial DIRECTLY instead of CONNECTing through
// the upstream. It is the operator-hop equivalent of the NO_PROXY every
// sandbox already honours INSIDE itself, and it follows that convention's
// spelling so nothing new has to be learned: each entry is either
//
// - a HOST or DOMAIN SUFFIX ("vpce.amazonaws.com", ".corp.internal",
// "registry.corp.internal") matched by label suffix — the entry itself,
// or any host ending in "."+entry, never a mid-label substring; or
// - a CIDR ("100.64.0.0/10", "10.40.0.0/16") matched against a literal-IP
// destination.
//
// It exists because a corporate forward proxy will not CONNECT to an
// internal address, so on a private-endpoint (PrivateLink) estate EVERY
// private endpoint times out while the upstream takes every dial. WHAT IT
// DOES NOT DO, and the reason it is safe: a bypassed dial falls through to
// the proxy's own unconditional private/reserved-IP SSRF guard exactly as an
// unproxied dial does, so bypassing a host does NOT make a private address
// reachable — InternalHosts (above) is still the one and only lift, and the
// two compose: the bypass routes the dial direct, InternalHosts lets that
// address through. It also grants no policy allow; allowed_domains/
// denied_domains decide reachability exactly as before.
//
// The NO_PROXY "*" wildcard is REFUSED at write time: "bypass everything" is
// spelled by clearing upstream_proxy_url, and accepting it here would let one
// character silently un-chain an estate's whole egress. Empty (the default)
// => every forward dial keeps chaining through the upstream, byte-identical
// to before this field existed.
UpstreamProxyNoProxy []string `json:"upstream_proxy_no_proxy,omitempty"`
// ArtifactOverrides maps an ecosystem ("npm"|"pip"|"cargo"|"maven"|"go"|
// "nuget") to its corporate artifact-registry redirect.
//
// Deprecated: superseded by EgressRedirects, which generalizes this from
// package registries to any outbound URL/host. Kept ONLY so the PUT
// /site-config request decoder and `wardyn site-config apply` keep accepting
// a document saved before this release — decodeStrict rejects unknown JSON
// fields, so removing this field would turn every such legacy body into a
// hard 400 instead of a fold. handlePutSiteConfig folds a non-empty value
// into EgressRedirects (rejecting a body that sets both) and never persists
// this field again; migration 0030 performs the same rewrite once, in place,
// on the one already-stored document. Never populated by a read from
// storage post-migration — treat a non-empty value outside the fold as
// legacy request input only.
ArtifactOverrides map[string]ArtifactOverride `json:"artifact_overrides,omitempty"`
// EgressRedirects is the operator's outbound redirect list: FROM a public/
// upstream URL or host, TO a corporate-internal replacement, generalizing
// ArtifactOverride from package registries to any destination (a container
// registry, a telemetry/SDK callback host, ...). Each entry is one of two
// tiers, discriminated by Ecosystem:
//
// - Ecosystem set (one of the ArtifactOverride closed set): FULL behavior,
// unchanged from the old ArtifactOverride — a per-tool config file
// (.npmrc/pip.conf/.cargo/config.toml/.m2/settings.xml/NuGet.Config/
// GOPROXY+GOSUMDB) via workspacescan.EmitArtifactConfig, PLUS egress
// substitution, PLUS token injection.
// - Ecosystem "" (NETWORK-ONLY): egress substitution (To's host allowed,
// From's host dropped) PLUS token injection for To's host, but NO config
// file — there is no ".npmrc equivalent" for an arbitrary host (a
// container registry, a telemetry endpoint, ...), and inventing one
// would be a lie about what Wardyn actually configures.
EgressRedirects []EgressRedirect `json:"egress_redirects,omitempty"`
// ScmHosts are the operator's default SCM hosts (e.g. "dev.azure.com",
// "github.example.com") the SCM Provider step / egress bundling consult.
ScmHosts []string `json:"scm_hosts,omitempty"`
// Integrations are the operator-configured external connections (AI
// providers, SCM hosts, generic connections) — the generalized replacement
// UpstreamProxySecretRef/EgressRedirects are migrating toward. Both the
// legacy fields and this one are read; nothing here removes the legacy
// fields yet. IntegrationList folds pre-base-component rows forward at
// decode and drops legacy artifact_mirror/host_proxy topology rows.
Integrations IntegrationList `json:"integrations,omitempty"`
// InternalHosts declares an internal hostname (an in-cluster service, a
// corporate registry, the internal model gateway) the proxy's unconditional
// private/reserved-IP SSRF guard would otherwise refuse regardless of
// policy. Each entry LIFTS that guard for addresses matching its
// host_suffix. LEAVE CIDRs EMPTY unless you know the addresses the SANDBOX
// resolves — empty is the full RFC1918/ULA/CGNAT liftable set, still scoped
// to the suffix, and it is the right default: a list drawn from what an
// operator's own machine resolves for a private endpoint (a corporate
// resolver's CGNAT answer) excludes the in-VPC address the sandbox actually
// gets, and the denial then looks identical to having no entry at all.
// Tighten only from sandbox-side resolution evidence. Never
// loopback/link-local/metadata/unspecified/multicast/NAT64, which stay denied
// unconditionally. The policy verdict
// (allowed_domains/denied_domains) still has to allow the host separately —
// this only lifts the SSRF builtin. Admin-only; validated at write time
// (validateInternalHosts) so every declared CIDR lies inside
// ipguard.Liftable. Empty (the default) => no lift, byte-identical to today.
InternalHosts []InternalHost `json:"internal_hosts,omitempty"`
// WorkspaceProviders is the org's workspace-provider POLICY — which git
// hosts a run may clone from and with which credential lanes, plus the
// ephemeral/drive storage ceilings. See WorkspaceProviders (a POINTER on
// purpose: a value struct's omitempty is a no-op, which would add
// "workspace_providers":{} to every 0.7.1-shaped GET /site-config and break
// the byte-identical upgrade claim). Nil (the default) is legacy open mode.
//
// Written through its own GET/PUT /workspace-providers endpoints AND through
// PUT /site-config, where an ABSENT key carries the stored value forward
// (siteConfigFieldsAfter066) and an explicit {} clears it — the site-config
// door is what makes providers MDM-deliverable to a laptop, whose
// deploy/wardyn-desktop.sh re-applies /etc/wardyn/site-config.json on every
// boot.
WorkspaceProviders *WorkspaceProviders `json:"workspace_providers,omitempty"`
// AgentProviders is the org's agent-enablement POLICY — which coding agents
// this deployment offers, the ONE model-access lane each may use, and whether
// that credential is shared or per-person. See AgentProviders (a POINTER for
// the same byte-identical-GET reason WorkspaceProviders is one). Nil (the
// default) is legacy open mode: the image map decides availability and the
// existing credential precedence chain decides auth, exactly as in 0.7.1.
//
// Written through its own GET/PUT /agent-providers endpoints AND through
// PUT /site-config on the same terms as the sibling block above — an absent
// key carries the stored value forward, an explicit {} clears it — which is
// what makes the agent roster MDM-deliverable to a laptop.
AgentProviders *AgentProviders `json:"agent_providers,omitempty"`
// EffectiveScmHosts is READ-ONLY and SERVER-OWNED: the one spelling of
// "which git hosts does this deployment actually admit" — ScmHosts MINUS
// every host a present provider row claims, UNION the hosts of every ENABLED
// provider row's base URLs (internal/api's effectiveScmHosts).
//
// It is a real field of this struct rather than a response-wrapper key
// because `wardyn site-config get > f && wardyn site-config apply f` decodes
// with DisallowUnknownFields — a wrapper-only key would 400 that documented
// round trip. PUT /site-config therefore IGNORES a submitted value (cleared
// before the write, the way Integrations is carried forward) and the console
// strips it from every GET-spread write body
// (SERVER_OWNED_SITE_CONFIG_KEYS). Projected on read; never stored.
EffectiveScmHosts []string `json:"effective_scm_hosts,omitempty"`
// OnboardingCompletedAt records when an operator finished (or deliberately
// left) the Getting Started funnel on THIS INSTALL. Nil until then.
//
// It lives here, server-side, because it is a fact about the install and
// every previous attempt to keep it in the browser was wrong in a way that
// shipped: a localStorage flag outlives the install it describes (a wiped
// database kept skipping its own funnel) and is scoped to an ORIGIN, so the
// same console reached at 127.0.0.1 and at localhost disagreed about
// whether onboarding had happened.
//
// NOT settable through PUT /site-config: the handler IGNORES a
// client-supplied value and carries the stored one forward, exactly as it
// does for Integrations — otherwise a naive GET-then-PUT round-trip by an
// older client would silently erase it. Ignored, never REFUSED: GET emits
// this key, so the documented capture/apply round-trip and the
// MDM-delivered /etc/wardyn/site-config.json echo it back on every write,
// and the carry-forward already makes a submitted value inert. A dropped
// value is reported instead — onboarding_completed_at_ignored in PUT's
// response body, which `wardyn site-config apply` prints as a warning.
OnboardingCompletedAt *time.Time `json:"onboarding_completed_at,omitempty"`
}
SiteConfig is the operator-wide, admin-authored baseline every run inherits: a corporate upstream proxy, egress redirects (package-registry mirrors and, more generally, any outbound URL/host/IP redirect), and default SCM hosts. It is the ONE net-new persistence surface the enterprise Getting-Started enhancements introduce (the Host Proxy and Corporate Network / Egress Redirection steps read it; everything else rides secrets + grants). There is exactly one SiteConfig for the operator (a store singleton); GetSiteConfig returns the zero value when none has been written yet — "unconfigured" is a valid, common state, not an error.
Secret VALUES never live here — only secret NAMES (refs) the broker/proxy resolve at dispatch/injection time, mirroring how RunPolicySpec's GrantSpec.Scope references secrets by name rather than embedding them.
type Source ¶ added in v0.5.0
type Source struct {
ID uuid.UUID `json:"id"`
Kind SourceKind `json:"kind"`
// Locator is the identity: a host directory path (local_dir, trailing
// slashes trimmed) or the canonical repo slug/clone URL (repo, lowercased).
// Together with Ref it is UNIQUE in the store — the dedupe rule.
Locator string `json:"locator"`
// Ref is an optional git ref (repo only; "" otherwise). Part of identity:
// the same repo at two refs is two sources with two contracts.
Ref string `json:"ref,omitempty"`
Name string `json:"name"`
// Requirements is this source's OWN contract. See the type doc above.
Requirements map[string]WorkspaceRequirement `json:"requirements,omitempty"`
// Profile is this source's scan result (workspacescan.WorkspaceProfile),
// opaque here — this type never interprets it, only persists/returns it
// (matching Workspace.Profile's own doc). json.RawMessage, not a plain
// []byte: encoding/json base64-encodes a bare []byte, which shipped a
// tier-1 source's scan profile to GET /sources as an opaque base64 string
// instead of real JSON (WIRE-2) — the one other place a source's profile
// crossed the wire (source_scan.go) already worked around this by hand
// (json.RawMessage(fresh.Profile)).
Profile json.RawMessage `json:"profile,omitempty"`
// Status is the source's scan lifecycle — the same one-word states the
// workspace used to own: pending_scan | scanning | scanned | error.
Status WorkspaceStatus `json:"status"`
// ActiveRunID fences this source's in-flight scan run, exactly as the
// workspace's own field fenced whole-workspace scans before the retarget.
ActiveRunID *uuid.UUID `json:"active_run_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Source is one tier-1 library entry: a repo or directory configured once and attached to many workspaces. Its Requirements are what THIS source expects of any sandbox it is used in ("a given repo has its expected requirements") — the same "<type>:<key>" grammar Workspace.Requirements documents, MINUS integration: keys (integrations compose at the aggregate, tier 3, by owner decision; the fold would pass them through unharmed, so relaxing later is a validation change only).
type SourceKind ¶ added in v0.5.0
type SourceKind string
SourceKind is a library source's kind. Ephemeral scratch is NOT a kind here: an ephemeral row is a per-workspace inline attachment (nothing to configure, scan, or share), never a library entity.
const ( SourceLocalDir SourceKind = "local_dir" SourceRepo SourceKind = "repo" )
type StorageEnforcement ¶ added in v0.7.0
type StorageEnforcement string
StorageEnforcement names WHAT ACTUALLY BINDS BYTES for a drive, and it exists because a size that nothing enforces must not be rendered as a limit.
The honesty sentence this vocabulary carries, verbatim, wherever a size is shown: "Wardyn never enforces a drive's size itself. On Kubernetes the size is the volume request and the storage class decides whether it binds — block disks do, network-share provisioners do not. On Docker a managed drive has no byte cap, the same gap disk_mib has. A share is bounded by its own quota. The size you see is the allocation, not a guarantee."
It is introduced ONCE here and it is now also the vocabulary the two DiskMiB warn sites were consolidated ONTO (0.7.2): the k8s sandbox's warning is gone — that substrate SETS the agent container's resources.limits[ephemeral-storage] and reports `eviction` — and the docker driver's storage-opt warning stays, reporting `filesystem` when the daemon can enforce a per-container size quota and `none` when it cannot. The word is SURFACED, not just logged: runner.Capabilities.EphemeralDiskEnforcement carries it to the admin setup status, so a size no substrate will hold is visible as such rather than discoverable only in a log line.
const ( // StorageEnforcementFilesystem: the filesystem itself refuses the write // (XFS project quota). NOTHING in v1 reports this — it is the value the // documented operator recipe earns, and naming it here is what keeps a // future quota lane from inventing a fifth word. StorageEnforcementFilesystem StorageEnforcement = "filesystem" // StorageEnforcementEviction: the kubelet enforces it, and it is MEASURED // PERIODICALLY — over the limit the POD IS KILLED, not the write. This is // what a Kubernetes run's ephemeral disk cap (the agent container's // resources.limits[ephemeral-storage]) actually is: the agent never sees // ENOSPC, it sees its pod evicted, and in-flight work is lost. Never // `filesystem` — nothing on this path refuses a byte. A burst between two // housekeeping ticks can also overshoot the limit before the kubelet looks. StorageEnforcementEviction StorageEnforcement = "eviction" // StorageEnforcementRequest: the size is a scheduling REQUEST (a PVC's // resources.requests.storage). A block storage class binds it; an NFS/EFS // provisioner accepts it and enforces nothing. StorageEnforcementRequest StorageEnforcement = "request" // StorageEnforcementExternal: something outside Wardyn binds it — the NAS's // own quota. Wardyn displays the allocation and claims nothing. StorageEnforcementExternal StorageEnforcement = "external" // StorageEnforcementNone: nothing binds it. A Docker named volume has no // cap at all (--storage-opt size caps only the writable layer, never a // volume, and project quota needs a capability the control plane must not // gain). StorageEnforcementNone StorageEnforcement = "none" )
func EnforcementFor ¶ added in v0.7.0
func EnforcementFor(b DriveBackend) StorageEnforcement
EnforcementFor maps a backend to what actually binds its bytes. An unknown backend reads as `none`: claiming enforcement for a row this binary does not understand is the one answer that could mislead an admin into trusting a cap.
type StorageProviders ¶ added in v0.7.2
type StorageProviders struct {
// Ephemeral bounds the writable scratch a run gets when it mounts no drive.
Ephemeral *EphemeralProvider `json:"ephemeral,omitempty"`
// UserDrive is the org switch and ceiling over user drives.
UserDrive *UserDriveProvider `json:"user_drive,omitempty"`
}
StorageProviders is the file-system half of WorkspaceProviders. Each nil sub-block is legacy behaviour for that half; the ENFORCEMENT of these numbers lives at one dispatch site and one drive-resolver site, not here.
type ToolEffect ¶ added in v0.7.0
type ToolEffect string
ToolEffect is what a matching ToolRule does with the call.
const ( // ToolAllow runs the call with no human in the loop. It is still recorded. ToolAllow ToolEffect = "allow" // ToolHold raises an approval and blocks — today's behaviour for every call. ToolHold ToolEffect = "hold" // ToolDeny refuses the call without asking anyone. ToolDeny ToolEffect = "deny" )
type ToolRule ¶ added in v0.7.0
type ToolRule struct {
Tool string `json:"tool"`
Effect ToolEffect `json:"effect"`
}
ToolRule is one entry in ToolRules: a tool NAME and what to do with it.
Tool is matched EXACTLY and case-sensitively against the name the harness reports (e.g. "Read", "Bash", "WebFetch"). Deliberately not a pattern: a glob over tool names invites a rule that reads narrower than it matches, and the set of tools a harness exposes is small and enumerable. The one wildcard is the literal "*", which sets the default for unmatched tools.
type UIApp ¶ added in v0.6.0
type UIApp struct {
Name string `json:"name"`
Port int `json:"port"`
Path string `json:"path,omitempty"`
}
UIApp is one declared in-sandbox HTTP app the UI gateway may relay to (RunPolicySpec.UIApps). Three fields, no command string:
- Name identifies the app to the operator and to the launcher convention (/usr/local/bin/wardyn-ui-<name>), so it is constrained to a short lower-case slug — it reaches a filesystem path and a URL query, and neither may ever see a slash, a space or "..".
- Port is the port the app listens on INSIDE the sandbox, on 127.0.0.1. The sandbox has no other reachable address (invariant 3) and the relay dials nothing else.
- Path is where the app's UI lives, used as the landing path after the gateway's ticket handoff. Empty means "/".
func (UIApp) PathOrRoot ¶ added in v0.6.0
PathOrRoot is Path with the empty default applied.
type UserDrive ¶ added in v0.7.0
type UserDrive struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Backend DriveBackend `json:"backend"`
// HostRoot is the operator-mounted tree a host_path drive binds a
// subdirectory of. Absolute and cleaned, empty for every other backend. It
// is authored in the DB by an admin and bound into OTHER PEOPLE's
// sandboxes, so the security ceiling over it is the env allowlist
// (UserDriveHostRootCheck), never this column alone.
HostRoot string `json:"host_root,omitempty"`
// StorageClass is the k8s_pvc provisioner to request; "" means the
// cluster default. Meaningless for every other backend (a static PVC was
// provisioned by someone else, and Docker has no such concept).
StorageClass string `json:"storage_class,omitempty"`
HomeTemplate HomeTemplate `json:"home_template"`
// SizeMiB is the ALLOCATION, not a guarantee — see StorageEnforcement. 0
// means "no allocation shown" on every backend but k8s_pvc, where the value
// IS the PVC's resources.requests.storage and 0 is therefore refused at the
// write boundary rather than stored as a claim no cluster would bind.
SizeMiB int `json:"size_mib,omitempty"`
Writable bool `json:"writable,omitempty"`
Reclaim DriveReclaim `json:"reclaim"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by,omitempty"`
}
UserDrive is one admin-registered drive (migration 0054's user_drives row).
Name is the UNIQUE human handle: what an admin allocates by, what the console lists, the source of a PVC's slug, and the last tie-break in the resolver's ORDER BY — so LIMIT 1 stays deterministic when priority ties, exactly as GovernanceProfile.Name does for a ceiling.
Writable is the drive's DEFAULT posture and it defaults to FALSE. A grant may narrow it and a run may narrow it again; neither may widen it.
type UserDriveGrant ¶ added in v0.7.0
type UserDriveGrant struct {
ID uuid.UUID `json:"id"`
SubjectType CapabilitySubjectType `json:"subject_type"`
Subject string `json:"subject"`
DriveID uuid.UUID `json:"drive_id"`
// Priority breaks ties WITHIN a tier (higher wins) — the group tier's
// working lever, since a member is typically in several groups at once. It
// does NOT cross tiers.
Priority int `json:"priority"`
// SizeMiBOverride replaces the drive's allocation for this subject. 0 means
// "use the drive's".
SizeMiBOverride int `json:"size_mib_override,omitempty"`
// WritableOverride is TRI-STATE on purpose: nil is "use the drive's
// posture", and an explicit false is an admin saying "this subject reads
// only" even on a writable drive. A plain bool could not tell the two
// apart, so an unset override would silently mean read-only.
WritableOverride *bool `json:"writable_override,omitempty"`
// HomeOverride names this subject's directory verbatim (Bob's NAS home is
// `bsmith`, whatever the template would derive). USER TIER ONLY: on a group
// or all row it would give every member of the group ONE home, which is the
// opposite of the isolation binding a per-user subdirectory buys.
HomeOverride string `json:"home_override,omitempty"`
// Enabled is the admin's off switch that keeps the row (and its overrides)
// intact. A disabled row is IN the resolver's query and can win its tier:
// the winner's bit is what folds to ResolvedDrive.Paused, so an admin
// turning one off tells that member "your allocation is paused" instead of
// silently dropping them through to the wider row beneath it.
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
}
UserDriveGrant allocates one drive to one subject (migration 0054's user_drive_grants row).
SubjectType REUSES CapabilitySubjectType — the same user/group/all vocabulary capability_grants and governance_assignments are written against, and the same one capabilitySubjects resolves a caller into. A second enum meaning the same three things is the dual-matcher drift this tree refuses everywhere: two definitions of "who" that disagree by one case is how a deny stops biting.
Every override is a column on the BINDING row rather than a second table, because an override is a property of "this subject on this drive" and has no meaning apart from the pair.
type UserDriveHostRootCheck ¶ added in v0.7.0
UserDriveHostRootCheck is the signature of the deployment's ENV CEILING over admin-authored host_path drives (WARDYN_USER_DRIVE_HOST_ROOTS), returning nil when hostRoot is inside an allowed root.
It is a hook rather than a rule inside ValidateUserDrive because this package cannot read the environment and must not: the ceiling is operator/MDM-set, exactly like the member-mount roots, and site config (a full-replace row one bad PUT can blank) is the wrong home for a security ceiling. The API write boundary composes the two — shape here, ceiling there — and an UNSET env means no host_path drive may be authored at all, the same fail-closed posture WARDYN_MEMBER_WORKSPACE_ROOTS takes.
type UserDriveListItem ¶ added in v0.7.0
UserDriveListItem is one row of the admin Drives table: the drive plus how many grants bind it.
The count is a READ-SIDE derivation and lives here rather than on UserDrive so the write type stays exactly the row: a client that GETs a list and PUTs an item back cannot resurrect a stale count as data. It is what makes the delete affordance honest — a drive with grants answers 409 (ON DELETE RESTRICT), and an admin should see why before clicking.
type UserDriveProvider ¶ added in v0.7.2
type UserDriveProvider struct {
// Disabled turns drives off deployment-wide — negative-sense like
// GitProvider.Disabled, so the zero value is today.
Disabled bool `json:"disabled,omitempty"`
// MaxSizeMiB is the largest drive this deployment allows: an allocation or
// override above it is refused at the admin write boundary and clamped at
// resolve. 0 = no ceiling.
MaxSizeMiB int `json:"max_size_mib,omitempty"`
}
UserDriveProvider is the org-level user-drive switch and ceiling.
Disabled is a DIFFERENT question from GovernanceLimits.DenyUserDrive, and the two are checked in this order: this ORG switch first ("this install offers no drives" — a 422 in the refused-backend family, because nobody was denied by a profile), then the per-profile DOOR (403 + authz.denied, unchanged).
type Workspace ¶
type Workspace struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
// Sources is the workspace's composition: one-or-more local_dir/repo/
// ephemeral sources.
Sources []WorkspaceSource `json:"sources"`
// BaseImage is the workspace's base-image choice. Nil means the platform
// default convention image for the detected/scanned stack.
BaseImage *WorkspaceBaseImage `json:"base_image,omitempty"`
// Requirements is the workspace's declared requirements contract: what
// secrets/egress/write-access/integrations a run against this workspace
// needs, keyed by a fixed grammar of "<type>:<key>" where type is one of:
//
// secret:NAME a named secret must be resolvable (the store secret
// NAME), e.g. "secret:acme-anthropic-key"
// egress:host the host must be reachable from the sandbox (a bare
// host or "*."-wildcard, matching RunPolicySpec.
// AllowedDomains grammar), e.g. "egress:api.github.com" —
// unioned into a run's allowlist alongside ApprovedEgress
// write:/host/path the given HOST path must be mounted writable, e.g.
// "write:/home/user/repo" — matches a Sources[] entry
// whose Path equals /host/path and Writable=true
// integration:ID the named Integration (SiteConfig.Integrations[i].ID)
// must be granted — its hosts opened and its credential
// (if any) presented, e.g. "integration:git_host:
// github.com" (ID itself may contain colons; see the
// split rule below)
//
// The map key is exactly "<type>:<key>", type and key joined by ONE colon.
// A key parser MUST split on the FIRST colon only (never the last): the
// type prefix is always one of the four fixed tokens above, so splitting
// on the first colon unambiguously separates it from the key even though a
// host path (a write: key) or an adopted legacy id (an integration: key)
// may itself legally contain colons.
//
// THREE-TIER SPLIT: this map is now the workspace's OVERLAY — what this
// workspace additionally declares or restates on top of what its attached
// sources' own contracts contribute. EffectiveRequirements below is the
// folded result a run actually consumes.
Requirements map[string]WorkspaceRequirement `json:"requirements,omitempty"`
// Attachments is the tier-3 composition: ordered source references and
// inline ephemeral rows, each with per-attachment target/writable and the
// workspace's requirement OVERRIDES for that source (workspace_contract.go).
// Empty on a pre-split row that hasn't migrated its embedded Sources yet —
// the store's hydrate pass falls back to the embedded column then.
Attachments []WorkspaceAttachment `json:"attachments,omitempty"`
// BaseImageID references the shared base-image catalog (tier 2). Nil means
// "recommended" — the per-workspace derived build — by design, not absence.
BaseImageID *uuid.UUID `json:"base_image_id,omitempty"`
// EffectiveRequirements is DERIVED, read-only, never persisted: the
// FoldWorkspaceContract result over attachments + attached source
// contracts + the overlay above. This is the map run-create and preflight
// consume. Populated by the store's hydrate pass; on a pre-split row it
// equals Requirements verbatim (the fold's zero-source identity).
EffectiveRequirements map[string]WorkspaceRequirement `json:"effective_requirements,omitempty"`
// Kind mirrors Sources[0].Type (single-source only).
Kind WorkspaceKind `json:"kind"`
// Source mirrors Sources[0]'s host path (local_dir) or repo slug/URL (repo)
// (single-source only).
Source string `json:"source"`
// Ref mirrors Sources[0].Ref (single-source repo only).
Ref string `json:"ref,omitempty"`
// DefaultTarget mirrors Sources[0].Target (single-source only).
DefaultTarget string `json:"default_target,omitempty"`
// Profile is internal/workspacescan's WorkspaceProfile, opaque here. Nil/empty
// until scanned.
Profile json.RawMessage `json:"profile,omitempty"`
// ImageRef is the resolved/generated image for this workspace's profile
// empty until scanned/built.
ImageRef string `json:"image_ref,omitempty"`
// BuiltProfileHash is the profile hash ImageRef was built from — the
// build-once/reuse-many cache key (rebuild only when the profile hash changes).
BuiltProfileHash string `json:"built_profile_hash,omitempty"`
// ApprovedEgress is the OPERATOR-owned list of egress hosts explicitly
// promoted for this workspace (typically from the scanner's content-derived
// SuggestedEgress, which is advisory and never auto-allowed). Unioned into a
// run's allowlist alongside the scanned profile's EgressDomains. Never
// written by a scan; cleared when the composition changes.
ApprovedEgress []string `json:"approved_egress,omitempty"`
// DeniedEgress is the OPERATOR-owned mirror of ApprovedEgress: hosts
// explicitly blocked for this workspace, written by a `deny · always`
// approval decision (AddWorkspaceEgressDecision) or the denied-egress PUT
// (SetWorkspaceDeniedEgress). Folded into a run's denied_domains at
// create time, where deny beats allow, allow_all_egress, and a runtime
// first-use approval alike. UNLIKE ApprovedEgress, this is NOT cleared
// when the workspace's sources change (see migration 0040): that reset
// rule is right for a WIDENING (a stale allow should fail closed), but
// applying it to a NARROWING would silently drop an operator's permanent
// deny the moment content changed — a deny needs no re-review against new
// content.
DeniedEgress []string `json:"denied_egress,omitempty"`
// EgressEditedAt is when an operator last REPLACED one of the two lists
// above through the approved-egress/denied-egress PUTs — the documented
// undo for an `always` decision. Nil on a workspace whose lists have only
// ever been written by decisions (or never written at all).
//
// It exists for exactly one reader, the boot heal
// (ReconcileWorkspaceEgressDecisions), which re-applies decided `always`
// approvals and would otherwise put back a host the operator had just
// removed: the approval row still says APPROVED/always forever, so without
// a "the operator has spoken more recently than that" mark the undo is
// re-widened at every restart. Stamped ONLY by those two scoped setters —
// never by AddWorkspaceEgressDecision (a decision is not an override of
// itself) and never by UpdateWorkspace (a composition edit restates every
// column, so stamping there would suppress the heal for unrelated edits).
EgressEditedAt *time.Time `json:"egress_edited_at,omitempty"`
// ActiveRunID is the in-flight scan/record run for this workspace, so the
// import panel can poll "is my step still running" without scanning all
// runs. Nil when no import step is executing.
ActiveRunID *uuid.UUID `json:"active_run_id,omitempty"`
// RecordResults is the per-task Record Mode state (taskKey → result: run
// pointer, mode, status, observations captured server-side from the run's
// audit events). Opaque map[string]api-owned JSON; written only via the
// scoped SetWorkspaceRecordResult; cleared when the composition changes
// (recordings were reviewed against the OLD sources).
RecordResults json.RawMessage `json:"record_results,omitempty"`
// LLMCred is the OPERATOR-owned model/harness credential binding: a run that
// picks this workspace inherits it (refs/names only). Nil => no binding.
// Folded into the run policy at create (applyWorkspaceCreds); written only
// via the scoped SetWorkspaceLLMCred, mirroring ApprovedEgress.
LLMCred *WorkspaceLLMCred `json:"llm_cred,omitempty"`
// OwnedBy is the MEMBER who created this workspace (their lowercased OIDC
// sub or email — the same dual-key identity a capability_grants `user`
// subject carries). EMPTY means OPERATOR-OWNED, which is every workspace an
// admin creates and every workspace that existed before migration 0048: an
// operator-owned row stays member-READABLE and admin-writable exactly as it
// always was, so an upgrade changes nothing until a member creates their
// first owned workspace.
//
// A non-empty value is the ownership relation ownsWorkspaceOrAdmin keys on:
// only that member (or an admin) may read or write the row, and a foreign
// member gets the byte-identical 404 a missing workspace gets — no existence
// oracle, the posture AgentRun.CreatedBy already has for runs. Set ONLY by
// handleCreateWorkspace from the authenticated session; never accepted from
// a request body, and never rewritten by an edit (UpdateWorkspace does not
// carry the column).
OwnedBy string `json:"owned_by,omitempty"`
Status WorkspaceStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Workspace is an onboarded, admin-reviewed COMPOSITION of one-or-more sources (local_dir | repo | ephemeral — an ephemeral scratch dir is the floor, so Sources is never empty) a run may attach, plus a base image choice and a requirements contract. Import scans/reviews the composition ONCE and persists a profile; runs thereafter reference the workspace by id instead of a free-text host path or repo slug. Repos are re-cloned fresh per run but reuse the scan-once Profile.
Profile is internal/workspacescan's WorkspaceProfile serialized opaquely: this type never interprets it, only persists/returns it — internal/workspacescan owns the shape and BuiltProfileHash cache-keying. Both are empty until Status transitions out of pending_scan.
type WorkspaceAttachment ¶ added in v0.5.0
type WorkspaceAttachment struct {
SourceID *uuid.UUID `json:"source_id,omitempty"`
// Ephemeral marks an inline scratch row (never a library entity).
Ephemeral bool `json:"ephemeral,omitempty"`
// Target is the in-sandbox mount/clone/scratch path for THIS attachment.
Target string `json:"target,omitempty"`
// Writable opts a local_dir attachment into read-write (per-attachment,
// not per-source: the same dir can be writable in one workspace and
// read-only in another).
Writable bool `json:"writable,omitempty"`
// Overrides is this workspace's stance on requirement keys the attached
// source declares: reqKey -> off|optional|required. "off" means THIS
// workspace refuses that requirement (mount the repo read-only to read
// code, without its build secrets/egress) — it never edits the shared
// source. Keys the source doesn't declare are ignored harmlessly.
Overrides map[string]string `json:"overrides,omitempty"`
}
WorkspaceAttachment is one tier-3 composition row: EITHER a library source reference (SourceID set) or an inline ephemeral scratch row (Ephemeral true, SourceID nil). Order is load-bearing — attachments[0] is the primary, the same rule Sources[0] carried before the split.
type WorkspaceBaseImage ¶ added in v0.5.0
type WorkspaceBaseImage struct {
// Kind is "recommended" (Wardyn's own convention image for the detected
// stack), "registry" (an operator-picked published image, named by Image),
// "custom" (Image is a base; see Steps below for what actually happens to
// it), or "byo" (Image is used verbatim).
Kind string `json:"kind"`
// Image is the base image reference. Meaning depends on Kind: the FROM for
// "custom", the image itself for "registry"/"byo", unused for "recommended".
Image string `json:"image,omitempty"`
// Steps is CATALOG IDENTITY ONLY. It is never applied to an image, and it
// must not be — this is a deliberate refusal, not an unfinished feature.
//
// Running operator-authored RUN lines would execute on the HOST Docker daemon
// during the wrap (resolveWorkspaceImage wraps Image verbatim), outside every
// confinement tier — the same host-RCE class assertWrapSafeBase refuses a
// hostile ONBUILD trigger for. So a "custom" image builds and runs
// identically to "byo": Image verbatim.
//
// The FIELD stays, and deleting it would be a live bug rather than a
// cleanup: it participates in the base_images UNIQUE identity index
// alongside (kind, image). Stop writing it and every upsert records NULL,
// so a pre-existing row carrying non-empty steps stops conflict-matching and
// the next passthrough resolve mints a DUPLICATE catalog row for the same
// (kind, image). The user-facing surface that pretended these did something
// was removed in 0.7 (D3); the identity component was not.
Steps []string `json:"steps,omitempty"`
}
WorkspaceBaseImage is a Workspace's base-image choice: what the sandbox's container image is built FROM, independent of its Sources.
type WorkspaceBedrockRef ¶ added in v0.4.0
type WorkspaceBedrockRef struct {
Region string `json:"region,omitempty"`
Model string `json:"model,omitempty"`
}
WorkspaceBedrockRef is a workspace's Bedrock model selection (non-secret; the AWS credentials themselves come from the store / mounted ~/.aws, unchanged).
type WorkspaceKind ¶
type WorkspaceKind string
WorkspaceKind discriminates an onboarded Workspace's (legacy, single-source) shape. As of the composition model it is a DERIVED READ-ONLY value (see Workspace.Kind) mirroring Sources[0] — a multi-source Workspace has no single Kind.
const ( WorkspaceKindLocalDir WorkspaceKind = "local_dir" WorkspaceKindRepo WorkspaceKind = "repo" // WorkspaceKindContainer was the pre-composition-model shape: an onboarded // base IMAGE with no mount (Source was the image ref). The composition // model replaces it — a "container" workspace is now an ephemeral Source // plus a custom BaseImage — so this value is never derived by // deriveWorkspaceMirrors and never written for a new workspace; migration // 0029 rewrites every stored 'container' row into that shape. // // Deprecated: kept for one release so 0029's backfill SQL ('container') has // a readable Go anchor, and so any pre-0029 client/fixture payload that // still carries "kind":"container" keeps decoding. WorkspaceKindContainer WorkspaceKind = "container" )
type WorkspaceLLMCred ¶ added in v0.4.0
type WorkspaceLLMCred struct {
// IntegrationRef names the Integration (SiteConfig.Integrations[i].ID) this
// workspace's model/harness access resolves through.
IntegrationRef string `json:"integration_ref,omitempty"`
}
WorkspaceLLMCred is the OPERATOR-owned model/harness credential BINDING on a workspace: a run that picks this workspace inherits this model access via the named Integration (one of the AI-provider kinds) — the generalized replacement for the old inline {mode, api_key_secret, bedrock} shape. Refs/ NAMES only, never secret values (the SiteConfig precedent): the actual secret lives in the store, resolved/injected via the Integration at dispatch. Mirrors ApprovedEgress's operator-owned discipline (never scan- written; cleared on source change). Nil / IntegrationRef="" => no binding; the run uses the global provider config, or is a plain governed command when it needs no model.
func (*WorkspaceLLMCred) UnmarshalJSON ¶ added in v0.5.0
func (c *WorkspaceLLMCred) UnmarshalJSON(b []byte) error
UnmarshalJSON decodes WorkspaceLLMCred, tolerating the pre-Integration wire shape a workspace's llm_cred JSONB column may still hold from before this type change ({"mode":"managed"|"api_key"|"bedrock","api_key_secret":"...", "bedrock":{...}}): those fields have no home on this type anymore, so they are silently dropped and the result is an EMPTY IntegrationRef ("no binding") rather than a decode error. There is deliberately no reverse mapping (mode=api_key -> a synthesized integration ref) — a legacy binding names no Integration, so it degrades to unbound rather than guessing one.
type WorkspaceMount ¶
type WorkspaceMount struct {
Source string `json:"source"`
Target string `json:"target"`
ReadOnly *bool `json:"read_only,omitempty"`
}
WorkspaceMount is one operator/policy-controlled host bind mount. Source is a host path (must be an absolute, cleaned path not under a denied location); Target is the in-container path (must be under an allowed prefix, e.g. /home/agent, /work, or /workspace).
ReadOnly is a *bool so the SAFE DEFAULT is read-only: when the field is OMITTED (nil) the mount is mounted read-only. Read-write requires the policy author to EXPLICITLY set "read_only": false. (A plain bool would default to false == read-write, the unsafe direction; the pointer makes "unset" mean read-only.) Use ReadOnlyOrDefault to resolve the effective value.
func (WorkspaceMount) ReadOnlyOrDefault ¶
func (m WorkspaceMount) ReadOnlyOrDefault() bool
ReadOnlyOrDefault resolves the mount's effective read-only flag: an OMITTED (nil) read_only defaults to true (read-only), the safe default. Read-write is returned only when the policy explicitly set read_only=false.
type WorkspaceProviders ¶ added in v0.7.2
type WorkspaceProviders struct {
// Git are the git-provider rows: which hosts (and which paths on them) a
// run may clone from, and which credential lanes it may use there.
Git []GitProvider `json:"git,omitempty"`
// Storage is the file-system half — ephemeral scratch and user drives. A
// nil block is legacy behaviour for that half.
Storage *StorageProviders `json:"storage,omitempty"`
// GitPatBrokerEnabled is READ-ONLY and SERVER-OWNED, exactly like
// SiteConfig.EffectiveScmHosts: projected onto every GET from this
// deployment's own WARDYN_GIT_PAT_BROKER switch (Config.DisableGitPATBroker),
// never stored and never taken from a PUT body (handlePutWorkspaceProviders
// clears it before validating, the same way the SiteConfig door clears
// EffectiveScmHosts). It is the console's one way to know which of the PAT
// lane's two honesty postures (brokered vs. in-sandbox) THIS install is
// actually running under (#381) — a boot-time env switch, not a stored
// provider row, so it lives here rather than as an eighth GitProvider field.
//
// A POINTER, not a bool: the switch is off by exception, so a plain
// omitempty bool would render identically for "off" and "never projected
// (no providers configured)" — the one distinction a console rendering the
// PAT lane's label actually needs. nil is genuinely absent (no lanes UI to
// annotate); a present pointer is the real value, true or false.
GitPatBrokerEnabled *bool `json:"git_pat_broker_enabled,omitempty"`
}
WorkspaceProviders is the org's WORKSPACE-PROVIDER POLICY: which kinds of work a sandbox may be given, and the ceilings that work is held to. It is a sub-object of the SiteConfig singleton (like Integrations) rather than its own table — a closed Go struct validated at the write boundary needs no DDL (the doctrine stated at internal/types/governance.go and internal/api/capabilities.go), and site-config is the one org->desktop channel MDM already delivers (deploy/wardyn-desktop.sh re-applies /etc/wardyn/site-config.json on every boot, so a provider block in a table would be undeliverable to a laptop).
THE ZERO VALUE IS LEGACY OPEN MODE, and that is load-bearing: an upgraded install carries no provider rows, so every predicate over this block answers exactly what it answered before the block existed (see the api package's providersConfigured / providerFor). Nothing here is ever implied, derived or folded from the legacy SiteConfig.ScmHosts list — providers are authored only through the providers surface, and ScmHosts is never written by this feature.
URL VALIDATION DELIBERATELY LIVES IN internal/api (validateWorkspaceProviders) so it reuses the site-config write gate — validSiteURL / shellSafeSiteString / hostrules.ValidApprovedHost — instead of a second opinion about what a safe persisted URL is. This file is types and closed enums only.
The ONE internal import here is internal/adoscope, and it is deliberate: the Entra lane's ceiling and profile are written in the CLASSIFIER's capability vocabulary, and a second spelling of that enum in this package would be a second answer to "what does code_write permit". adoscope imports the standard library only, so the dependency cannot come back the other way.
func (*WorkspaceProviders) Empty ¶ added in v0.7.2
func (p *WorkspaceProviders) Empty() bool
Empty reports whether p carries no policy at all — the shape a caller PUTs to CLEAR the block. The write boundary normalizes an empty block to nil so a later GET does not render "workspace_providers":{} on every read forever (SiteConfig.WorkspaceProviders is a pointer for the same reason: a value struct's omitempty is a no-op).
type WorkspaceRepo ¶
type WorkspaceRepo struct {
Repo string `json:"repo"`
Target string `json:"target,omitempty"`
Ref string `json:"ref,omitempty"`
}
WorkspaceRepo is one operator/policy-controlled git repo attached to a run, paralleling WorkspaceMount for git-cloned (rather than bind-mounted) sources (the multi-workspace run model). Repo is a slug/URL validated the same way the legacy single-repo AgentRun.Repo field is (repoFieldSafe + repoCloneURL, runs.go). Target is an optional in-container clone destination; an empty Target defers to the ~/work/<name> convention a later wave wires up (plan B4, WARDYN_REPOS) — it is NOT resolved here. Ref is an optional git ref (branch/tag/sha) — same field WorkspaceSource carries as part of a repo source's identity (workspace.go); an empty Ref clones the remote's default branch, unchanged from before this field existed. Carried as a 4th tab-separated field in WARDYN_REPOS (buildRepoRecords, runs_scm.go) for agent-run-lib.sh's clone_one to check out (W9-S1-3 — previously advertised on the source's identity but never actually honored by any clone).
type WorkspaceRequirement ¶ added in v0.5.0
type WorkspaceRequirement struct {
// Level is "required" (a run against this workspace cannot proceed without
// it) or "optional" (a run may proceed without it, degraded).
Level string `json:"level"`
// Provenance is "scan_seeded" (the workspace scanner detected the need) or
// "operator_set" (an operator declared it directly).
Provenance string `json:"provenance"`
}
WorkspaceRequirement is one entry in a Workspace's requirements contract: a declared need for a secret, an egress host, or host write access, keyed by the grammar documented on Workspace.Requirements.
type WorkspaceSource ¶ added in v0.5.0
type WorkspaceSource struct {
Type WorkspaceSourceType `json:"type"`
// Path is the host directory path. local_dir only.
Path string `json:"path,omitempty"`
// Source is the repo slug/URL. repo only.
Source string `json:"source,omitempty"`
// Ref is an optional git ref (branch/tag/sha). repo only.
Ref string `json:"ref,omitempty"`
// Target is the mount/clone/scratch-dir path inside the sandbox.
Target string `json:"target,omitempty"`
// Writable opts a local_dir source into read-write (default false =
// read-only, matching WorkspaceMount.ReadOnly's safe default — this field is
// a plain bool rather than a *bool because false already IS the safe
// default, so there is no unsafe zero value to guard against). local_dir
// only.
Writable bool `json:"writable,omitempty"`
// Overrides is this attachment's stance on requirement keys ITS SOURCE
// declares: reqKey -> off|optional|required (workspace_contract.go's
// AttachmentOverride* constants); local_dir/repo only (ephemeral has no
// library contract to override). nil (the field omitted) means "say
// nothing here" — the server carries forward whatever this source's
// attachment already had (upsertAndAttach), so a plain composition edit
// (e.g. a rename) can never silently wipe an override set on an earlier
// PUT; an explicit map, empty or not, REPLACES it outright.
Overrides map[string]string `json:"overrides,omitempty"`
// Admitted is a SERVER-OWNED READ PROJECTION, never persisted and never taken
// from a write body (decodeWorkspaceRequest clears it): does this deployment's
// workspace-provider policy admit this repo source's clone URL today? repo
// sources only, and ABSENT entirely where no provider row exists — which is
// what keeps an upgraded install's workspace documents byte-identical to
// 0.7.1's.
//
// A POINTER so "no answer" (a local_dir source, legacy open mode) stays
// distinguishable from "not admitted". Projected by the server precisely so
// the console never re-implements the match rule: internal/api's admitRepoURL
// is its only spelling, and a second one in TypeScript is how a UI comes to
// call a repo fine while every run refuses it.
Admitted *bool `json:"admitted,omitempty"`
}
WorkspaceSource is one entry in a Workspace's composition — a Workspace is one-or-more of these (multiples of the same Type are allowed, e.g. two local_dir sources mounted at different targets). Which fields are meaningful depends on Type:
local_dir Path (host path), Target, Writable
repo Source (slug/URL), Ref, Target
ephemeral Target only — no Path, no Source, never Writable (there is no
host location for a scratch dir to be writable back TO)
type WorkspaceSourceType ¶ added in v0.5.0
type WorkspaceSourceType string
WorkspaceSourceType discriminates one entry in a Workspace's composition.
const ( // WorkspaceSourceTypeLocalDir binds a host directory into the sandbox. WorkspaceSourceTypeLocalDir WorkspaceSourceType = "local_dir" // WorkspaceSourceTypeRepo clones a git repo into the sandbox. WorkspaceSourceTypeRepo WorkspaceSourceType = "repo" // WorkspaceSourceTypeEphemeral is a scratch directory that exists only for // the sandbox's lifetime — no host mount, no clone. It is the composition // floor: a Workspace with no other source still has this one, so "one-or- // more sources" never means zero. WorkspaceSourceTypeEphemeral WorkspaceSourceType = "ephemeral" )
type WorkspaceStatus ¶
type WorkspaceStatus string
WorkspaceStatus is the onboarding/scan lifecycle of a Workspace. The import-pipeline stages (building/verifying/verify_failed/build_error/ready) a later "Workspace Import v2" wave had added are RETIRED as of the composition model — migration 0029 collapses every stored row back to scanned/pending_scan — so a workspace is either not-yet-scanned, mid-scan, scanned (ready to use), or errored.
const ( // WorkspacePendingScan is the initial state: onboarded but not yet scanned. WorkspacePendingScan WorkspaceStatus = "pending_scan" // WorkspaceScanning: a scan run is in flight (repo scan). WorkspaceScanning WorkspaceStatus = "scanning" // WorkspaceScanned: profile derived; the workspace is ready to use. WorkspaceScanned WorkspaceStatus = "scanned" // WorkspaceError means the last scan attempt failed. WorkspaceError WorkspaceStatus = "error" )