ADR 0002 · Reconcile CalibanTask → agent-sandbox Sandbox (+ per-task SA & NetworkPolicy)
- Status: accepted
- Date: 2026-07-04
- Amended by: ADR 0005 — decision 6 (status gains prospero's
AgentsSettledcondition as an input, via server-side apply field ownership) - Source: k8s system-design spec (§"
caliban-operator", §"agent-sandbox integration") in the caliban-ai docs hub · caliban #283 · epic #274 · builds on ADR 0001
Context
ADR 0001 fixed the controller stack
(kube 4.0 / k8s-openapi 0.28 / v1_32) and the CalibanTask API. #282 shipped a
placeholder reconcile that only initialized .status.phase = Pending. #283 makes
the reconcile real: turn a CalibanTask into a running, sandboxed caliband pod
reachable at a stable DNS, with per-task RBAC and network isolation, and drive
.status from the backing Sandbox.
The spec is explicit that the operator is a thin caliban-semantics layer over agent-sandbox — it must not reimplement pods/isolation. So the reconcile composes existing objects rather than managing pods directly.
Forces:
- agent-sandbox owns the
SandboxCRD (agents.x-k8s.io/v1beta1). We consume it — we must not generate, install, or drift-guard its schema. Verified v0.5.0SandboxSpec={ podTemplate{metadata,spec: core.PodSpec} (required), service bool, operatingMode enum(Running|Suspended)=Running, shutdownPolicy enum(Delete|Retain)=Retain, shutdownTime, volumeClaimTemplates []core.PVC };SandboxStatus.serviceFQDNis the pod's stable DNS. - Reconcile must be idempotent and self-healing — re-running on the same
CalibanTaskmust converge, not duplicate, and children must be garbage-collected when theCalibanTaskis deleted. - Cluster-agnostic (the eventual charts are public): no home-cluster identifiers baked into generated objects.
- k3s v1.31 target vs
v1_32compile surface (ADR 0001): stick to long-stablePodSpec/NetworkPolicyfields. - The caliband pod needs zero Kubernetes API access; its identity exists only to be a NetworkPolicy/PodSecurity subject.
Decision
-
Consume
Sandboxas a foreign typed resource. Hand-author a minimal RustSandboxvia#[derive(CustomResource)]pointed atgroup = "agents.x-k8s.io", version = "v1beta1", kind = "Sandbox", namespaced, status = "SandboxStatus", declaring only the fields we set or read (podTemplate,service,operatingMode,shutdownPolicy,shutdownTime,volumeClaimTemplates; statusserviceFQDN,conditions).podTemplatereusesk8s_openapiPodTemplateSpec;volumeClaimTemplatesreusesPersistentVolumeClaim. We never callSandbox::crd(), emit its YAML, or add it to the drift-sync test — agent-sandbox installs its own CRD. ExtraSandboxSpecfields we omit are pruned by the API server's structural schema; this is a read/write view, not the schema of record. -
Reconcile = pure builders + a thin apply loop. The mapping
CalibanTask → {Sandbox, ServiceAccount, NetworkPolicy}and the status derivation are pure functions (build_sandbox,build_service_account,build_network_policy,derive_status), unit-tested with no cluster. The async reconcile only: server-side-applies each child, reads back the Sandbox, and patchesCalibanTask.status. -
Idempotency via server-side apply + owner references. Children are applied with
Patch::Applyunder field managercaliban-operator(force), so re-reconciles converge on one object. Every child carries a controllerOwnerReferenceto itsCalibanTask, so deleting the task cascades. Child names are deterministic:<task>-sbx(Sandbox),<task>-sa(ServiceAccount),<task>-netpol(NetworkPolicy). -
Least-privilege RBAC = a dedicated, token-less ServiceAccount with no bound Role. The caliband pod is given its own per-task
ServiceAccountwithautomountServiceAccountToken: false, referenced from the podTemplate. Because noRole/RoleBindingis created, the pod holds zero API permissions — strictly the least privilege. An empty Role would be noise; the token-less SA is the RBAC posture. (The operator's own cluster RBAC — the right to create Sandboxes/SAs/NetworkPolicies — is deploy-time chart concern #284, not this reconcile.) -
NetworkPolicy: default-deny with the minimal working allowances. The per-task
NetworkPolicyselects the Sandbox's pod labels and setspolicyTypes: [Ingress, Egress]with: deny-all by default, allow DNS egress (UDP/TCP 53), allow general egress (git clone + provider APIs; the empty-torule allows egress to all destinations at MVP, in-cluster included — restricting it to non-cluster CIDRs is deferred because it would require an environment-specific cluster CIDR, a home-cluster identifier we don't bake in), and allow ingress on the caliband port (8443) from the task's namespace. The spec's finer rules (prospero→caliband, pod→gonzalo) require cross-namespace identity the CR does not carry generically at MVP; they are parameterized in a later iteration. The MVP ships a real, cluster-agnostic default-deny posture (selectors are the pod's own labels — no home-cluster identifiers). -
Status is derived from the Sandbox, not initialized-and-frozen. This evolves #282's init-once no-op:
derive_status(task, sandbox)returnsProvisioningonce the Sandbox is applied but has noserviceFQDNyet,RunningonceserviceFQDNis populated, and setscalibandEndpoint = "<serviceFQDN>:8443"andsandboxRefaccordingly.Pendingremains the pre-provision default (aCalibanTaskobserved before its first successful apply). The status patch is skipped when the derived status equals the observed one (no-op churn avoidance, same discipline as #282). -
Explicit deferrals (out of #283 scope, tracked upstream): the caliban-aware drain finalizer (needs caliband's checkpoint gRPC — deferred with the transport #314), idle→pause/resume mapping,
SandboxTemplate/SandboxWarmPool(Phase 4), and fullSecret/ConfigMapcredential projection. #283 projects only what the acceptance path needs: the workspace PVC, the caliband image + runtimeClass, and the gonzalo endpoint / model-router refs as env, so the pod comes up reachable.
Consequences
- A
CalibanTasknow materializes real cluster objects. Applying one yields a Sandbox (→ caliband pod on the configured RuntimeClass, workspace PVC, stable DNS), a token-less SA, and a default-deny NetworkPolicy;.statusreportsProvisioning → RunningwithcalibandEndpoint. This satisfies #283's acceptance and unblocks prospero'sK8sFleet(#64) reading the endpoint. - agent-sandbox must be installed in the cluster for the applies to succeed
(its
SandboxCRD must exist). This is the cluster prerequisite the umbrella chart bundles by default (helm-charts#6); without it the operator's Sandbox apply fails and the task staysProvisioning— an honest, retried error, not a crash. - The operator needs cluster RBAC to create these children — it can create
sandboxes.agents.x-k8s.io,serviceaccounts, andnetworkpolicies.networking.k8s.io, and patchcalibantasks/status. That ClusterRole ships with the operator chart (#284); this ADR fixes the exact verb set that chart must grant. - Deferring the drain finalizer means a deleted
CalibanTasktears down its Sandbox immediately (owner-ref GC) without checkpointing in-flight agents. This is acceptable for the MVP and revisited when caliband checkpoint gRPC lands. - The foreign
Sandboxview can rot if agent-sandbox changes its v1beta1 schema. We pin the consumed version (v1beta1) and cover the fields we use with round-trip tests against the published CRD's shape; a breaking upstream change surfaces as a deserialize/apply failure, caught in integration. serviceFQDNis not readiness — resolved (#45). Decision 6's "RunningonceserviceFQDNis populated" reportedReady=Trueas soon as the Service object existed, before caliband had bound its port; a crash-looping caliband could present asReadyindefinitely and prosperod dialed a dead endpoint. The caliband container now carries TCP readiness and startup probes on the control port (no liveness probe — a restart kills every supervised agent), andderive_statusgatesRunningon agent-sandbox's ownSandboxReadycondition, which v0.5.0 sets True only once the pod passes its readiness probe, has an IP, and the Service is ready. An FQDN without that condition staysProvisioningwith no endpoint, and a reported not-ready surfaces asReady=False/SandboxNotReadycarrying agent-sandbox's message.- Allow-all egress could not be tightened — resolved (#58). The default
egress rule above still allows every destination, and remains the default. A
Workspace may now declare
spec.egress.allow(CIDR + optional TCP ports), which replaces allow-all with DNS plus exactly those destinations; an empty list is DNS-only. It is a Workspace-level field rather than part of the sharedIsolationSpec, becauseCalibanTaskSpec.isolationis a per-run override that wins over the workspace default and would otherwise let a task widen the restriction. Malformed CIDRs and out-of-range ports fail the Workspace. volumeClaimTemplatescould not reusePersistentVolumeClaim— superseded (#7/#8). Decision 1 says theSandboxview'svolumeClaimTemplatesreusesk8s_openapi'sPersistentVolumeClaim. It cannot: that type serializes its group/version/kind, and agent-sandbox v0.5.0's structural schema rejects those on server-side apply (.spec.volumeClaimTemplates[].apiVersion: field not declared in schema), failing every reconcile.src/sandbox.rsdeclares a bareVolumeClaimTemplate(metadata+spec) instead, which is what the upstream schema actually describes.podTemplatedoes still reusePodTemplateSpecas decision 1 says.- The
Sandboxview omitsshutdownPolicyandshutdownTime— not implemented. Decision 1 lists both among the fields the view declares; it declarespodTemplate,service,operatingModeandvolumeClaimTemplatesonly. Nothing sets a shutdown policy today, and the view is deliberately a read/write subset — fields it omits are pruned by the API server, so the gap is inert rather than a defect. It is recorded here because decision 1 reads as a complete list and is not one.