types

package
v0.7.0 Latest Latest
Warning

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

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

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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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").

View Source
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.

View Source
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

View Source
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.

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).

View Source
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.

DriveBackends is the closed set in the order the admin surface shows them.

DriveReclaims is the closed set in admin-surface order.

HomeTemplates is the closed set in admin-surface order.

View Source
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

func AIProviderKind(kind string) bool

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

func ASCIIOnlySubject(s string) bool

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

func CanonicalGroupSubject(s string) (string, bool)

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

func CanonicalUserSubject(s string) string

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 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

func DriveHomeName(d UserDrive, subject, override string) (string, error)

DriveHomeName derives the per-user home segment for one principal.

PRECEDENCE, and the first rule is the whole point of the override existing:

  1. 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.
  2. 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.
  3. 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

func DriveObjectName(d UserDrive, home string) string

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

func DriveSlug(name string) string

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

func DriveSubjectHash(subject string) string

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:

  1. 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.
  2. An override of "off" drops that source's contribution for that key.
  3. An override lane (optional|required) replaces the source's lane for that contribution.
  4. 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.
  5. 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.
  6. 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

func SplitRequirementKey(key string) (typ, rest string, ok bool)

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

func ValidateUserDrive(d *UserDrive, runnerTarget string) error

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 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.

const (
	ActorHuman  ActorType = "human"
	ActorAgent  ActorType = "agent"
	ActorSystem ActorType = "system"
)

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"`
}

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"
)

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"
)

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: sensor backpressure drops
  "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 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.

const (
	// DriveKindManaged: Wardyn allocates the object for the member.
	DriveKindManaged DriveKind = "managed"
	// DriveKindShare: the object exists already and Wardyn only binds it.
	DriveKindShare DriveKind = "share"
)

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 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 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"`
}

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.

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"`
}

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 GitHub pushes. 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

func (s RunState) IsTerminal() bool

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, scoped to its own CIDRs (or the full RFC1918/ULA/CGNAT set
	// when CIDRs is empty) — 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"`
	// 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 is the vocabulary the two existing DiskMiB warn sites (the docker driver's storage-opt warning and the k8s sandbox's) adopt when they are consolidated — the third instance is the trigger, and this is the second.

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"
	// 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 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

func (a UIApp) PathOrRoot() string

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

type UserDriveHostRootCheck func(hostRoot string) error

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

type UserDriveListItem struct {
	UserDrive
	GrantCount int `json:"grant_count"`
}

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 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 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"`
}

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"
)

Jump to

Keyboard shortcuts

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