Webhook & Egress Security¶
The operator exposes two categories of inbound HTTP paths that carry untrusted external data: SCM webhooks (GitHub/GitLab push and issue events) and Grafana alert callbacks. It also receives internal callbacks from short-lived agent pods. Each path has a different threat model and a correspondingly different authentication mechanism.
flowchart TD
A([Webhook arrives]) --> B{Body <= 5 MiB?}
B -- no --> Z1([413 - Request Entity Too Large])
B -- yes --> C{Recognize provider\nfrom headers?}
C -- no --> Z2([400 - unrecognized provider])
C -- yes --> D[Look up Project CR]
D -- not found --> Z3([404 - unknown project])
D -- found --> E{Provider matches\nProject spec?}
E -- no --> Z4([400 - provider mismatch])
E -- yes --> F[Read webhookSecret\nfrom scmSecretRef]
F -- error --> Z5([500])
F -- ok --> G{Signature valid?}
G -- GitHub: hmac.Equal X-Hub-Signature-256 --> H
G -- GitLab: subtle.ConstantTimeCompare X-Gitlab-Token --> H
G -- mismatch --> Z6([401 - verification failed])
H([202 - route to handler]) Webhook HMAC verification¶
GitHub¶
GitHub signs every webhook delivery with the X-Hub-Signature-256 header:
The operator verifies this using crypto/hmac with sha256.New and compares the result with hmac.Equal, which is constant-time and prevents timing attacks:
m := hmac.New(sha256.New, []byte(secret))
m.Write(body)
if !hmac.Equal([]byte(got), []byte(want)) {
// reject 401
}
A missing or malformed header, or a hex value that does not match, returns 401 Unauthorized. The body is always read before the check so a timing difference between "header missing" and "header invalid" does not exist.
GitLab¶
GitLab uses a static shared token rather than a per-payload HMAC. The token is sent as a plain header:
The operator validates it with subtle.ConstantTimeCompare from crypto/subtle to prevent timing attacks. A missing or mismatched token returns 401.
Grafana alerts¶
The Grafana alert endpoint (POST /operator/webhooks/{project}/grafana) is separate from the SCM webhook route and requires the Project to have spec.grafana.enabled: true. Authentication uses HTTP Bearer:
The operator strips the Bearer prefix and compares the token with subtle.ConstantTimeCompare against the webhookSecret key from the Secret named in spec.grafana.secretRef. Mismatch returns 401.
Grafana alert events that are not firing are silently accepted (202) and discarded.
Provider mismatch guard¶
Before reading the webhook secret, the operator checks that the provider identified from the delivery headers (GitHub's X-GitHub-Event or GitLab's X-Gitlab-Event) matches the spec.scm.provider configured on the Project CR. A mismatch returns 400 Bad Request without ever touching the secret.
This prevents a cross-project routing mistake (a GitHub delivery arriving at a GitLab-configured project) from producing a misleading 401 bad_signature response that would hide the configuration error.
Body size limit¶
All webhook bodies are capped at 5 MiB before any parsing or signature work. Deliveries exceeding this limit return 413 Request Entity Too Large. The SCM retries them, but tatara will never process them.
The webhookSecret key in the scmSecret¶
The operator reads the shared secret from a Kubernetes Secret in the operator namespace. The Secret name is the value of spec.scmSecretRef on the Project CR. The key within that Secret must be named exactly webhookSecret.
apiVersion: v1
kind: Secret
metadata:
name: my-project-scm # matches spec.scmSecretRef
namespace: tatara
stringData:
webhookSecret: "your-webhook-secret-here"
token: "<bot-pat>" # the SCM PAT; key is exactly `token`
The operator validates and reads exactly two keys from this Secret: token (the bot PAT) and webhookSecret. A Project whose scmSecretRef Secret is missing or empties either key is held not-ready with a SecretMissingKeys condition.
In the recommended tatara-helmfile deployment pattern the Secret is rendered from SOPS-encrypted values:
# tatara-helmfile values/tatara-operator/prod.secrets.yaml (sops-encrypted)
scmWebhookSecret: "your-webhook-secret-here"
scmSecretName: "my-project-scm"
The scmWebhookSecret value in values.yaml is the chart scalar that lands in the rendered Secret under the webhookSecret key. Do not set it in plaintext; always supply it through an encrypted values overlay.
Empty webhookSecret fails at delivery, not at startup
The secret is read inside the webhook handler, not at operator boot. If the Secret exists but the webhookSecret key is missing or empty, the operator returns 500 Internal Server Error on the first webhook delivery for that project (the operator process itself starts normally; the Project's reconcile additionally flags SecretMissingKeys). Provision and verify the secret before registering the webhook URL with your SCM provider.
Callback HMAC (optional)¶
When an agent turn completes, the wrapper pod POSTs to the operator's internal callback endpoint:
This endpoint is exposed on internalAddr (default :8082), reachable only via the in-cluster tatara-operator-internal Service. NetworkPolicy is the primary guard - only pods with the operator's selector labels can reach port 8082.
For environments where that NetworkPolicy boundary is considered insufficient, an additional HMAC can be configured. Set callbackHmacSecretName in the operator values to the name of a Secret whose callback-hmac-secret key holds a shared secret:
# values.yaml (supplied by tatara-helmfile encrypted overlay)
callbackHmacSecretName: "tatara-callback-hmac"
callbackHmacSecret: "your-callback-hmac-secret-here"
When configured, every wrapper pod receives the secret via a SecretKeyRef environment variable (CALLBACK_HMAC_SECRET) and signs its callback body:
The operator verifies this with hmac.Equal (constant-time). Requests with a missing or mismatched signature return 401 Unauthorized.
When callbackHmacSecretName is empty the HMAC check is skipped entirely and NetworkPolicy remains the sole control. There is no partial state: the feature is either fully on (both operator and wrapper share the secret) or fully off.
Should you enable callback HMAC?
Enable it if you run the operator at replicaCount > 1 and are concerned about a compromised agent pod constructing spoofed turn-complete payloads to manipulate Task state. For most single-replica deployments the NetworkPolicy boundary is sufficient.
Callback URL constraints¶
Two distinct callbackUrl values exist on this path, with different trust levels and different validation.
The operator reads its own callback base URL from callbackUrl in the chart values (env CALLBACK_URL) and derives the value it hands each wrapper pod by appending /internal/turn-complete, injected as DEFAULT_CALLBACK_URL (internal/agent/pod.go):
This value is operator-configured, not derived from untrusted input, and the operator applies no URL validation to it - there is no SSRF exposure to guard against here.
The wrapper pod's own POST /v1/messages endpoint accepts a second, per-turn callbackUrl in the request body (postMessageReq.CallbackURL), which overrides DEFAULT_CALLBACK_URL for that turn only when non-empty (cmd/wrapper/app.go, defaultCB fallback). Because this value can originate from a caller of the wrapper's API rather than from operator-controlled chart values, it is validated: validateCallbackURL in tatara-claude-code-wrapper/internal/httpapi/messages.go:16-65 rejects anything but the http/https scheme, the literal host localhost, loopback addresses, unspecified addresses (0.0.0.0, ::), link-local addresses (covers the cloud metadata IP), and private ranges via net.IP.IsPrivate() (RFC1918 IPv4 and IPv6 unique-local fc00::/7). It does not resolve hostnames, so a private IP reachable only via DNS is not caught by this guard. Validation runs in postMessage (messages.go:86) before the turn is submitted.
Agent pod hardening¶
Agent (wrapper) pods are spawned dynamically by the operator's task controller. Their security context is built from the PodConfig struct, driven by operator-level values.
Non-root UID¶
The wrapper image runs as a named non-root user. To lock the container to a numeric UID (required by runAsNonRoot: true - the kubelet will reject a named user string):
# values/tatara-operator/common.yaml
agentRunAsNonRoot: true
agentRunAsUser: 10001 # wrapper image UID; must be numeric
agentFsGroup: 10001 # optional: volume ownership
Default is agentRunAsNonRoot: false
The chart ships cluster-agnostic defaults. The deploying helmfile must explicitly opt in to non-root enforcement. Setting agentRunAsNonRoot: true without a numeric agentRunAsUser is an unsatisfiable contract: the wrapper image runs as a named (non-numeric) user, so the kubelet rejects each spawned wrapper pod with CreateContainerConfigError. Note there is no operator-side fail-fast for this today - a ValidatePodSecurityContext guard with exactly that message exists in the code but is not wired into the boot path, so the operator starts and the misconfiguration only surfaces per-pod at spawn time. Always pair agentRunAsNonRoot: true with a numeric agentRunAsUser (the wrapper image UID, e.g. 10001).
Network isolation¶
Agent pods carry the label tatara.dev/managed-by: tatara-operator and are selected by the managedPodNetworkPolicy described in the next section. That policy is the primary pod-level hardening control: it restricts both ingress (operator-only) and egress (DNS + allowlisted services).
Restart policy¶
All wrapper pods are launched with RestartPolicy: Never. The operator's boot crash handler monitors ContainerStatus.State.Terminated.Message (populated from the container's last stdout/stderr on non-zero exit) and surfaces the crash reason without requiring log API access. A crashed pod is never automatically restarted by the kubelet; the operator decides whether to respawn.
Network egress allowlist¶
The chart ships a NetworkPolicy that applies to all pods labeled tatara.dev/managed-by: tatara-operator (agent pods and repo-ingester Jobs). It is enabled by default via managedPodNetworkPolicy.enabled: true.
Ingress to agent pods¶
Only the operator itself may initiate connections to agent pods, and only on port 8080 (the wrapper's HTTP API). No other ingress is permitted.
Egress from agent pods¶
| Destination | Port | Purpose |
|---|---|---|
kube-dns (any namespace) | 53 UDP + TCP | DNS resolution |
tatara-memory pods | 8080 TCP | Memory graph reads/writes |
| Operator pods (same selector) | 8080 TCP | REST API (MCP tools, turn submission) |
| Operator pods (same selector) | 8082 TCP | Turn-complete callback (/internal/turn-complete) |
| Any namespace | 443 TCP | SCM API (GitHub/GitLab), Anthropic API, Keycloak |
An agent pod talks to the forge over that last rule. The operator's own read path to the forge is rate-limited: a per-project egress token bucket, shared by every reader and writer, with Retry-After and X-RateLimit-Reset honoured and a typed rate-limit error that requeues a Task rather than failing it. It is the last line of defence against a sweep, a reconcile storm and an agent poll loop coinciding on the same forge. Breaches are counted by operator_scm_ratelimited_total{provider,path,limit_type}.
The external HTTPS rule (443 to any namespace) is intentionally broad. CIDR tightening for GitHub/Anthropic/Keycloak endpoints is deferred because the IP ranges are not stable and maintaining them would be brittle. The rule covers exactly the traffic agents need for SCM operations and Claude API calls; all other ports and protocols are denied by default-deny egress semantics.
Internet egress for brainstorm pods¶
Brainstorm tasks configured with the internet source (for WebSearch / WebFetch tools) require unrestricted outbound HTTPS. The operator stamps the label tatara.io/egress: internet on these pods at spawn time.
A separate NetworkPolicy named tatara-egress-internet must be applied manually via kubectl apply (it is not helm-managed, per the cluster-agnostic chart rule):
# deploy-samples/tatara-egress-networkpolicy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tatara-egress-internet
namespace: tatara
spec:
podSelector:
matchLabels:
tatara.io/egress: internet
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 443
protocol: TCP
This policy is additive: it grants internet egress on top of the baseline tatara-operator-managed-pods policy. Brainstorm pods that do not carry the internet label are unaffected. Pods without any label (memory, neo4j, CNPG) are not selected by either policy and retain open egress until the namespace receives a default-deny rule.
Applying a namespace-wide default-deny egress policy requires auditing all pods
The tatara-egress-internet policy does not introduce a default-deny rule. Adding one would newly restrict the memory, neo4j and CNPG pods that currently have open egress. Harden the namespace only after mapping the required egress for every workload it contains.
Reporter intake gate (prompt-injection defense)¶
The webhook handler applies an allowlist check before creating or reactivating any Task. The effective check for a given issue or comment:
- Bot login - always accepted, unconditionally.
- Maintainer logins (
spec.scm.maintainerLoginsor per-repo override) - always accepted. - Reporter logins (
spec.scm.reporterLoginsor per-repo override) - accepted when the list is non-empty and the author is listed. - Empty reporter list - open behavior; any author is accepted.
An empty actor login (which could indicate a malformed webhook) is rejected when an active reporter gate exists. The gate fires on both direct issue events and issue_comment events, so a comment from an unlisted account cannot reactivate a parked task or queue an interjection into a live session.
See Prompt-Injection Defenses for the full layered defense model, including the bot-identity gate and the headless-agent picker-denial.