# SandboxTemplate CRD

> Reusable template type used by SandboxClaim and SandboxWarmPool, including the embedded Sandbox spec it encapsulates.

- Repository: kubernetes-sigs/agent-sandbox
- GitHub: https://github.com/kubernetes-sigs/agent-sandbox
- Human wiki: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a
- Complete Markdown: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a/llms-full.txt

## Source Files

- `extensions/api/v1beta1/sandboxtemplate_types.go`
- `extensions/api/v1beta1/groupversion_info.go`
- `k8s/crds/extensions.agents.x-k8s.io_sandboxtemplates.yaml`
- `extensions/examples/sandboxtemplate.yaml`
- `extensions/examples/secure-sandboxtemplate.yaml`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [extensions/api/v1beta1/sandboxtemplate_types.go](extensions/api/v1beta1/sandboxtemplate_types.go)
- [extensions/api/v1beta1/groupversion_info.go](extensions/api/v1beta1/groupversion_info.go)
- [k8s/crds/extensions.agents.x-k8s.io_sandboxtemplates.yaml](k8s/crds/extensions.agents.x-k8s.io_sandboxtemplates.yaml)
- [extensions/examples/sandboxtemplate.yaml](extensions/examples/sandboxtemplate.yaml)
- [extensions/examples/secure-sandboxtemplate.yaml](extensions/examples/secure-sandboxtemplate.yaml)
- [extensions/api/v1beta1/sandboxclaim_types.go](extensions/api/v1beta1/sandboxclaim_types.go)
- [extensions/api/v1beta1/sandboxwarmpool_types.go](extensions/api/v1beta1/sandboxwarmpool_types.go)
- [extensions/controllers/sandboxtemplate_controller.go](extensions/controllers/sandboxtemplate_controller.go)
- [extensions/controllers/sandboxclaim_controller.go](extensions/controllers/sandboxclaim_controller.go)
- [extensions/controllers/sandboxwarmpool_controller.go](extensions/controllers/sandboxwarmpool_controller.go)
- [extensions/controllers/utils.go](extensions/controllers/utils.go)
- [api/v1beta1/sandbox_types.go](api/v1beta1/sandbox_types.go)
</details>

# SandboxTemplate CRD

`SandboxTemplate` is the reusable, namespaced blueprint that describes how an agent sandbox should be built. It is part of the `extensions.agents.x-k8s.io` API group and is consumed by `SandboxClaim` (one-shot rentals) and `SandboxWarmPool` (pre-provisioned pools). The template encapsulates the Pod shape, persistent storage, environment-variable injection policy, headless `Service` opt-in, and a shared `NetworkPolicy` that the template controller materializes into the cluster.

This page covers the API shape of `SandboxTemplate`, the constants and enums that govern its semantics, how the embedded `Sandbox` spec (`PodTemplate`, `VolumeClaimTemplates`, `Service`) is propagated to derived `Sandbox` objects, and how the dedicated template controller manages a single shared `NetworkPolicy` per template.

## API identity

The type is registered under the `extensions.agents.x-k8s.io` group at version `v1beta1`. It is namespaced, exposes the short name `sandboxtemplate`, and is registered into the scheme by the package `init()`.

```go
// extensions/api/v1beta1/groupversion_info.go
GroupVersion = schema.GroupVersion{Group: "extensions.agents.x-k8s.io", Version: "v1beta1"}
```

```go
// extensions/api/v1beta1/sandboxtemplate_types.go
// +kubebuilder:resource:scope=Namespaced,shortName=sandboxtemplate
type SandboxTemplate struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty,omitzero"`
    Spec SandboxTemplateSpec `json:"spec"`
}
```

The generated CRD confirms the group/kind/plural names, lists `v1beta1` as the served and stored version, and marks `spec.podTemplate` as the only required spec field.

Sources: [extensions/api/v1beta1/groupversion_info.go:25-36](), [extensions/api/v1beta1/sandboxtemplate_types.go:141-167](), [k8s/crds/extensions.agents.x-k8s.io_sandboxtemplates.yaml:1-19](), [k8s/crds/extensions.agents.x-k8s.io_sandboxtemplates.yaml:4142-4149]()

## Shape of `SandboxTemplateSpec`

`SandboxTemplateSpec` reuses the embedded `PodTemplate` and `PersistentVolumeClaimTemplate` types defined in the core sandbox API (`sigs.k8s.io/agent-sandbox/api/v1beta1`), then adds the policy fields that distinguish a template from a raw `Sandbox`.

| Field | Type | Required | Default | Purpose |
|---|---|---|---|---|
| `podTemplate` | `sandboxv1beta1.PodTemplate` | yes | - | Pod metadata + `corev1.PodSpec` used to materialize each sandbox Pod. |
| `volumeClaimTemplates` | `[]sandboxv1beta1.PersistentVolumeClaimTemplate` | no | empty | PVCs created per derived sandbox; list is atomic. |
| `networkPolicy` | `*NetworkPolicySpec` | no | nil → secure default | Ingress/egress rules applied to the shared `NetworkPolicy`. |
| `networkPolicyManagement` | `NetworkPolicyManagement` enum | no | `Managed` | Whether the controller creates/owns the `NetworkPolicy`. |
| `envVarsInjectionPolicy` | `EnvVarsInjectionPolicy` enum | no | `Disallowed` | Whether a `SandboxClaim` may inject env vars. |
| `service` | `*bool` | no | nil | Opt-in/out for a headless `Service` per sandbox. |

The embedded Pod and PVC types come from the core sandbox API:

```go
// api/v1beta1/sandbox_types.go
type PodTemplate struct {
    Spec corev1.PodSpec `json:"spec"`
    ObjectMeta PodMetadata `json:"metadata"`
}
type PersistentVolumeClaimTemplate struct {
    EmbeddedObjectMetadata `json:"metadata"`
    Spec corev1.PersistentVolumeClaimSpec `json:"spec"`
}
```

Sources: [extensions/api/v1beta1/sandboxtemplate_types.go:73-139](), [api/v1beta1/sandbox_types.go:109-127]()

### Enums and constants

The template package declares two string-typed enums and one well-known label key. All three are central to how the template binds to derived sandboxes and Pods.

```go
// extensions/api/v1beta1/sandboxtemplate_types.go
const (
    SandboxIDLabel = "agents.x-k8s.io/claim-uid"

    NetworkPolicyManagementManaged   NetworkPolicyManagement = "Managed"
    NetworkPolicyManagementUnmanaged NetworkPolicyManagement = "Unmanaged"

    EnvVarsInjectionPolicyAllowed    EnvVarsInjectionPolicy = "Allowed"
    EnvVarsInjectionPolicyOverrides  EnvVarsInjectionPolicy = "Overrides"
    EnvVarsInjectionPolicyDisallowed EnvVarsInjectionPolicy = "Disallowed"
)
```

| Enum | Allowed values | CRD default | Effect |
|---|---|---|---|
| `NetworkPolicyManagement` | `Managed`, `Unmanaged` | `Managed` | `Unmanaged` short-circuits the template controller and lets external systems (e.g. Cilium) own networking. |
| `EnvVarsInjectionPolicy` | `Allowed`, `Overrides`, `Disallowed` | `Disallowed` | Gate that `SandboxClaim.spec.env` is evaluated against by the claim controller. |

Sources: [extensions/api/v1beta1/sandboxtemplate_types.go:33-56](), [k8s/crds/extensions.agents.x-k8s.io_sandboxtemplates.yaml:31-37](), [k8s/crds/extensions.agents.x-k8s.io_sandboxtemplates.yaml:223-228]()

### Restricted `NetworkPolicySpec`

`NetworkPolicySpec` is deliberately a **subset** of `networkingv1.NetworkPolicySpec`. Only `ingress` and `egress` are exposed; `PodSelector` and `PolicyTypes` are intentionally excluded because the template controller fills them in to guarantee a default-deny posture targeted at the template's hashed pod selector.

```go
// extensions/api/v1beta1/sandboxtemplate_types.go
type NetworkPolicySpec struct {
    Ingress []networkingv1.NetworkPolicyIngressRule `json:"ingress,omitempty"`
    Egress  []networkingv1.NetworkPolicyEgressRule  `json:"egress,omitempty"`
}
```

Sources: [extensions/api/v1beta1/sandboxtemplate_types.go:58-71](), [extensions/api/v1beta1/sandboxtemplate_types.go:91-114]()

## Structural relationship

The diagram below shows the embedded reuse of the core sandbox `PodTemplate`/`PersistentVolumeClaimTemplate` and the two consumers that reference the template by name.

```mermaid
classDiagram
    class SandboxTemplate {
        +ObjectMeta metadata
        +SandboxTemplateSpec spec
    }
    class SandboxTemplateSpec {
        +PodTemplate podTemplate
        +[]PersistentVolumeClaimTemplate volumeClaimTemplates
        +*NetworkPolicySpec networkPolicy
        +NetworkPolicyManagement networkPolicyManagement
        +EnvVarsInjectionPolicy envVarsInjectionPolicy
        +*bool service
    }
    class PodTemplate {
        +PodMetadata metadata
        +corev1.PodSpec spec
    }
    class PersistentVolumeClaimTemplate {
        +EmbeddedObjectMetadata metadata
        +corev1.PersistentVolumeClaimSpec spec
    }
    class NetworkPolicySpec {
        +[]NetworkPolicyIngressRule ingress
        +[]NetworkPolicyEgressRule egress
    }
    class SandboxTemplateRef {
        +string name
    }
    class SandboxClaim {
        +SandboxTemplateRef sandboxTemplateRef
    }
    class SandboxWarmPool {
        +SandboxTemplateRef sandboxTemplateRef
    }
    SandboxTemplate --> SandboxTemplateSpec
    SandboxTemplateSpec --> PodTemplate
    SandboxTemplateSpec --> PersistentVolumeClaimTemplate
    SandboxTemplateSpec --> NetworkPolicySpec
    SandboxClaim --> SandboxTemplateRef
    SandboxWarmPool --> SandboxTemplateRef
    SandboxTemplateRef ..> SandboxTemplate : by name
```

Sources: [extensions/api/v1beta1/sandboxtemplate_types.go:73-139](), [extensions/api/v1beta1/sandboxclaim_types.go:101-128](), [extensions/api/v1beta1/sandboxwarmpool_types.go:24-47]()

## How consumers dereference the template

Both `SandboxClaim` and `SandboxWarmPool` carry a `SandboxTemplateRef` that is a bare name (no namespace, no UID). The reference is resolved at reconcile time, and the resolved template's `Spec.PodTemplate`, `Spec.VolumeClaimTemplates`, and `Spec.Service` are deep-copied into the generated `Sandbox`. The template ref name is also annotated and hashed onto labels so that the shared `NetworkPolicy` and warm-pool bookkeeping can target the right Pods.

```go
// extensions/api/v1beta1/sandboxclaim_types.go
type SandboxTemplateRef struct {
    Name string `json:"name,omitempty"`
}

type SandboxClaimSpec struct {
    TemplateRef SandboxTemplateRef `json:"sandboxTemplateRef,omitempty"`
    ...
}
```

```go
// extensions/api/v1beta1/sandboxwarmpool_types.go
const TemplateRefField = ".spec.sandboxTemplateRef.name"

type SandboxWarmPoolSpec struct {
    Replicas    int32                          `json:"replicas"`
    TemplateRef SandboxTemplateRef             `json:"sandboxTemplateRef,omitempty"`
    UpdateStrategy *SandboxWarmPoolUpdateStrategy `json:"updateStrategy,omitempty"`
}
```

The `SandboxClaim` controller copies the template into the `Sandbox` and stamps identity labels on the Pod template:

```go
// extensions/controllers/sandboxclaim_controller.go
sandbox.Annotations[v1beta1.SandboxTemplateRefAnnotation] = template.Name
template.Spec.PodTemplate.DeepCopyInto(&sandbox.Spec.PodTemplate)
sandbox.Spec.Service = template.Spec.Service
for i, vct := range template.Spec.VolumeClaimTemplates { vct.DeepCopyInto(&sandbox.Spec.VolumeClaimTemplates[i]) }
sandbox.Spec.PodTemplate.ObjectMeta.Labels[sandboxTemplateRefHash] = SandboxTemplateRefHash(template.Name)
```

The `SandboxWarmPool` controller does the same and additionally computes a JSON-marshalled hash of `template.Spec.PodTemplate` (`SandboxPodTemplateHashLabel`) so that pool members can be detected as "stale" when the template drifts:

```go
// extensions/controllers/sandboxwarmpool_controller.go
specJSON, err := json.Marshal(template.Spec.PodTemplate)
// ... NameHash(string(specJSON)) -> currentPodTemplateHash
PodTemplate: sandboxv1beta1.PodTemplate{
    Spec:       *template.Spec.PodTemplate.Spec.DeepCopy(),
    ObjectMeta: sandboxv1beta1.PodMetadata{Labels: podLabels, Annotations: podAnnotations},
}
```

The hashed label key bound to the template name is defined once in the warm-pool controller and reused by all three controllers:

```go
// extensions/controllers/sandboxwarmpool_controller.go
sandboxTemplateRefHash = "agents.x-k8s.io/sandbox-template-ref-hash"
```

Sources: [extensions/api/v1beta1/sandboxclaim_types.go:101-128](), [extensions/api/v1beta1/sandboxwarmpool_types.go:24-47](), [extensions/controllers/sandboxclaim_controller.go:948-966](), [extensions/controllers/sandboxwarmpool_controller.go:303-379](), [extensions/controllers/sandboxwarmpool_controller.go:49-49]()

### Env var injection policy gate

`EnvVarsInjectionPolicy` is enforced in `SandboxClaimReconciler.createSandbox` after the template has been copied: if a claim supplies any `spec.env` while the template's policy is `Disallowed`, the claim is rejected. `Allowed` permits new variables but not overrides; `Overrides` permits both. The default in the CRD schema is `Disallowed`.

Sources: [extensions/api/v1beta1/sandboxtemplate_types.go:48-55](), [extensions/api/v1beta1/sandboxtemplate_types.go:123-128](), [extensions/controllers/sandboxclaim_controller.go:972-978]()

## Template controller: shared `NetworkPolicy`

`SandboxTemplateReconciler` watches `SandboxTemplate` and owns a single `NetworkPolicy` per template, named `<template>-network-policy` in the template's namespace. It does not create Pods, Services, or PVCs directly — those are the domain of the claim and warm-pool controllers — its only job is to materialize the shared NetworkPolicy.

```mermaid
flowchart TD
    subgraph User["User-authored objects"]
        T[SandboxTemplate]
        C[SandboxClaim]
        W[SandboxWarmPool]
    end
    subgraph Reconcilers["extensions/controllers"]
        TR[SandboxTemplateReconciler]
        CR[SandboxClaimReconciler]
        WR[SandboxWarmPoolReconciler]
    end
    subgraph Cluster["Materialized cluster state"]
        NP["NetworkPolicy &lt;tmpl&gt;-network-policy<br/>podSelector: sandbox-template-ref-hash"]
        SB[Sandbox]
        Pods[Pod with hashed template label]
        PVCs[PVCs from volumeClaimTemplates]
        Svc[headless Service]
    end
    T --> TR --> NP
    C -->|sandboxTemplateRef| CR
    W -->|sandboxTemplateRef| WR
    CR -->|DeepCopy PodTemplate, VCTs, Service| SB
    WR -->|DeepCopy PodTemplate, VCTs, Service| SB
    SB --> Pods
    SB --> PVCs
    SB --> Svc
    Pods -. matched by .-> NP
```

Sources: [extensions/controllers/sandboxtemplate_controller.go:38-154](), [extensions/controllers/sandboxclaim_controller.go:923-966](), [extensions/controllers/sandboxwarmpool_controller.go:303-382]()

### Reconcile branches

The controller branches on `Spec.NetworkPolicyManagement` and the presence of `Spec.NetworkPolicy`:

| Management | `spec.networkPolicy` | Behavior |
|---|---|---|
| `Unmanaged` | any | The template's NetworkPolicy (if any) is deleted; controller exits early. |
| `Managed` (or empty) | nil | `buildDefaultNetworkPolicySpec` is used: ingress from `app=sandbox-router`; egress to `0.0.0.0/0` and `::/0` minus RFC1918 and link-local. |
| `Managed` (or empty) | non-nil | User-provided `ingress`/`egress` are wrapped with controller-injected `PodSelector` (the template-name hash) and `PolicyTypes`. |

The controller compares the existing policy with `equality.Semantic.DeepEqual` and patches only on drift; if no policy exists it creates one and sets a controller reference back to the `SandboxTemplate`. The reconciler ignores objects whose `DeletionTimestamp` is set, relying on owner-reference garbage collection to clean up the policy.

```go
// extensions/controllers/sandboxtemplate_controller.go
npName := template.Name + "-network-policy"
if management == extensionsv1beta1.NetworkPolicyManagementUnmanaged {
    r.Delete(ctx, existingNP) // tolerate NotFound
    return ctrl.Result{}, nil
}
if template.Spec.NetworkPolicy == nil {
    desiredSpec = buildDefaultNetworkPolicySpec(template.Name)
} else {
    desiredSpec = networkingv1.NetworkPolicySpec{
        PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{
            sandboxTemplateRefHash: SandboxTemplateRefHash(template.Name),
        }},
        PolicyTypes: []networkingv1.PolicyType{Ingress, Egress},
        Ingress: template.Spec.NetworkPolicy.Ingress,
        Egress:  template.Spec.NetworkPolicy.Egress,
    }
}
```

Sources: [extensions/controllers/sandboxtemplate_controller.go:67-153](), [extensions/controllers/sandboxtemplate_controller.go:156-213]()

### Secure-by-default policy

When `networkPolicy` is omitted under `Managed`, the controller installs a deny-everything-internal policy: ingress is restricted to Pods labeled `app=sandbox-router`, and egress is `0.0.0.0/0` minus the three RFC1918 ranges and `169.254.0.0/16` (link-local / cloud metadata), plus an IPv6 catch-all that excludes `fc00::/7`. The accompanying `ApplySandboxSecureDefaults` helper also forces `AutomountServiceAccountToken=false` if unset and, only in secure-by-default mode, rewires `DNSPolicy` to `None` with explicit public resolvers to block internal DNS enumeration.

```go
// extensions/controllers/utils.go
if spec.AutomountServiceAccountToken == nil {
    automount := false
    spec.AutomountServiceAccountToken = &automount
}
isSecureByDefault := isManaged && template.Spec.NetworkPolicy == nil
if isSecureByDefault && spec.DNSPolicy == "" {
    spec.DNSPolicy = corev1.DNSNone
    spec.DNSConfig = &corev1.PodDNSConfig{Nameservers: []string{"8.8.8.8", "1.1.1.1"}}
}
```

The secure-by-default policy enforces a strict "Default Deny" ingress posture. As the field documentation warns, sidecars (Istio proxy, monitoring agents) that need their own ingress ports must be added explicitly to the `Ingress` list or they will fail health checks.

Sources: [extensions/controllers/sandboxtemplate_controller.go:156-213](), [extensions/controllers/utils.go:23-48](), [extensions/api/v1beta1/sandboxtemplate_types.go:91-114]()

## Volume claim templates and Service opt-in

`volumeClaimTemplates` is an atomic list of PVC templates; updates replace the entire list rather than merging. Both consumer controllers deep-copy each entry into the derived `Sandbox.Spec.VolumeClaimTemplates`, leaving downstream PVC creation to the core sandbox controller.

`spec.service` is a pointer to `bool` (it intentionally uses `*bool` rather than an enum to mirror the headless-`Service` field on the underlying `Sandbox`; see issue #746 referenced in the source). When set, the value is propagated verbatim into the generated `Sandbox.Spec.Service`. When unset, the controller "preserves existing Services for backward compatibility but does not create new ones."

Sources: [extensions/api/v1beta1/sandboxtemplate_types.go:82-89](), [extensions/api/v1beta1/sandboxtemplate_types.go:130-139](), [extensions/controllers/sandboxclaim_controller.go:950-958](), [extensions/controllers/sandboxwarmpool_controller.go:360-379]()

## Examples

The repository ships two illustrative templates under `extensions/examples/`. Note these example manifests are tagged with `apiVersion: extensions.agents.x-k8s.io/v1alpha1`, while the served/stored CRD version in `k8s/crds/` is `v1beta1` — examples have not been refreshed to match the current API version.

```yaml
# extensions/examples/sandboxtemplate.yaml
apiVersion: extensions.agents.x-k8s.io/v1alpha1
kind: SandboxTemplate
metadata:
  name: secure-datascience-template
spec:
  podTemplate:
    spec:
      securityContext:
        runAsUser: 1000
        runAsNonRoot: true
      containers:
      - name: my-container
        image: busybox
        command: ["/bin/sh", "-c", "sleep 36000"]
        volumeMounts: [{ name: workspace, mountPath: /workspace }]
  volumeClaimTemplates:
  - metadata: { name: workspace }
    spec:
      accessModes: ["ReadWriteOnce"]
      resources: { requests: { storage: 1Gi } }
```

The `secure-sandboxtemplate.yaml` example shows an explicit `networkPolicy` block that overrides the controller's secure default: the only allowed ingress is the Istio ingress gateway, and the only allowed egress is DNS on port 53 (UDP/TCP) — which implicitly denies traffic to the Kubernetes API server and to peer sandboxes.

```yaml
# extensions/examples/secure-sandboxtemplate.yaml (excerpt)
spec:
  podTemplate:
    spec:
      runtimeClassName: gvisor
      ...
  networkPolicy:
    ingress:
      - from:
        - namespaceSelector: { matchLabels: { istio-injection: enabled } }
          podSelector:       { matchLabels: { app: istio-ingressgateway } }
    egress:
      - ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
```

Sources: [extensions/examples/sandboxtemplate.yaml:1-39](), [extensions/examples/secure-sandboxtemplate.yaml:1-64]()

## Operational notes

- **Single shared `NetworkPolicy` per template.** Updating `spec.networkPolicy` updates the one policy object owned by the template; the CNI re-enforces rules across all existing and future sandboxes that match the hashed pod selector. There is no per-sandbox `NetworkPolicy`.
- **Template ref by name only.** Both `SandboxClaim.Spec.TemplateRef` and `SandboxWarmPool.Spec.TemplateRef` carry just a `name`. `SandboxWarmPool` exposes `TemplateRefField = ".spec.sandboxTemplateRef.name"` for indexer lookups; any rename of the JSON tag must be mirrored in this constant.
- **Pod template hashing for pools.** `SandboxWarmPool` JSON-marshals `template.Spec.PodTemplate` to detect drift; PodTemplate content changes drive `Recreate` or `OnReplenish` update strategies, but pure label/annotation changes do not (per the `Recreate` doc comment).
- **Required fields.** The CRD declares `spec` and `spec.podTemplate` as required; everything else is optional and defaulted as documented above.

The template is therefore best thought of as the immutable-style policy surface (security defaults, network policy, env-injection rules) plus the embedded `Sandbox` spec fragment (`podTemplate`, `volumeClaimTemplates`, `service`) that the claim and warm-pool controllers stamp into concrete `Sandbox` objects.

Sources: [extensions/api/v1beta1/sandboxtemplate_types.go:91-138](), [extensions/api/v1beta1/sandboxwarmpool_types.go:24-69](), [k8s/crds/extensions.agents.x-k8s.io_sandboxtemplates.yaml:4142-4149]()
