Helm charts¶
The patchy chart (in-repo at charts/patchy) renders the full stack: the patchy.bitwisemedia.uk CRDs, five
singleton controller Deployments — each with its own ConfigMap, ServiceAccount, and NetworkPolicy — the two Services
(integration :8080, source :9790), and the agent namespace with its RBAC and sandbox policies. The companion
patchy-config chart (charts/patchy-config) renders the Integration/Forge custom resources — a separate chart
because Helm validates every manifest against the API server before applying anything, so the CRs cannot ride in the
same first install as the CRDs they depend on. Both are published to oci://ghcr.io/bitwise-media-group/patchy/charts/
on every release; release-please stamps version and appVersion 1:1 with the app, and the default image tag is
v<appVersion> — chart X.Y.Z runs images vX.Y.Z.
helm install patchy oci://ghcr.io/bitwise-media-group/patchy/charts/patchy \
--version <X.Y.Z> --namespace patchy --create-namespace
helm install patchy-config oci://ghcr.io/bitwise-media-group/patchy/charts/patchy-config \
--version <X.Y.Z> --namespace patchy -f values.yaml
The chart requires Kubernetes ≥ 1.34 (the oldest line not yet end-of-life) and references (never creates) the two Secrets.
Human access: the admission policy¶
Findings carry five custom RBAC verbs — approve, retry, expedite, suspend, resume — that let a grant say "this
developer may approve findings, and nothing else". The status page honours them on its own because it
writes as its ServiceAccount and asks first; the CLI writes as the user, so the API server authorizes it,
and RBAC has no notion of a field: update on findings grants the whole object.
admissionPolicy.enabled (default true) renders a ValidatingAdmissionPolicy that binds each human-writable spec
field to its own verb, inside the API server's admission chain — so it holds for the CLI, kubectl edit,
kubectl patch, server-side apply and raw curl alike.
admissionPolicy:
enabled: true
# A controller of your own that legitimately writes Finding spec.
# The chart's own components are exempted automatically, from their
# resolved ServiceAccount names — so renaming the release or overriding
# a serviceAccount.name cannot lock a controller out.
extraExemptSubjects: []
Disabling it is a real reduction in privilege separation, not a cosmetic toggle: every custom verb becomes advisory, and
anyone holding update on findings can change any field, including forging an approval. The template refuses to render
on a cluster below 1.30 rather than silently omitting itself, because a missing policy looks exactly like a working one
until someone tests it.
Values¶
Values are validated against values.schema.json — a typo'd or relocated key fails the install instead of being
silently ignored. Everything specific to one controller lives under that controller's top-level key; only genuinely
shared settings stay global.
Global: images, CRDs¶
| Key | Default | Purpose |
|---|---|---|
image.repository |
ghcr.io/bitwise-media-group/patchy |
Repository prefix (registry included); the binary name is appended |
image.tag |
"" |
Empty = v<appVersion> |
image.pullPolicy |
IfNotPresent |
|
image.pullSecrets |
[] |
|
crds.install |
true |
Render the CRDs as templates (so upgrades track schema changes) |
crds.keep |
true |
Stamp helm.sh/resource-policy: keep — uninstall never deletes the CRDs, or the FindingRollup statistics with them |
commonLabels |
{} |
Extra labels on every rendered object |
commonAnnotations |
{} |
Extra annotations on every rendered object, pods included (per-object annotations win) |
Per-component image overrides win key-by-key, and a digest pins over any tag: <controller>.image and
agent.runners.<harness>.image — the latter is the runner image the job controllers stamp into every Job that harness
runs (PATCHY_<HARNESS>_AGENT_IMAGE). Unlike kustomize, pinning a runner's digest here is one knob, not two.
The pipeline switch-on: the patchy-config chart¶
The controllers idle until an Integration and a Forge exist. They install via the separate patchy-config chart —
into the same namespace as the patchy release, after it — whose integrations / forges list entries each render
one CR. Its values.schema.json embeds the CRD spec schemas (regenerated by mise run codegen), so a typo'd or
mistyped spec field fails the install client-side before the API server sees it. See the
install page for a worked example and
deploy/kustomize/base/crs.example.yaml for the full field walkthrough. The referenced Secrets are yours to create;
kubectl apply-ing the CRs directly instead of using the chart works just as well.
The webhook entry point¶
A provider has one webhook URL, so exposure is chart-level: webhook.host plus exactly one flavour, both fronting the
integration-controller — see Webhook exposure.
| Key | Default | Purpose |
|---|---|---|
webhook.host |
"" |
The single external hostname (required when a flavour is enabled) |
webhook.ingress.{enabled,className,annotations,tls} |
false, … |
Plain-Ingress flavour |
webhook.httpRoute.{enabled,annotations,parentRefs} |
false, … |
Gateway API flavour; TLS is the Gateway's concern |
Shared pipeline config¶
Each controller renders its own ConfigMap (consumed with envFrom): the shared config.* keys plus its own config
block; every key is the matching PATCHY_* variable minus the prefix. Any shared key can be repeated under
<controller>.config to override it for that controller alone.
| Key | Default | Purpose |
|---|---|---|
config.logLevel |
warn |
debug, info, warn, error |
config.maxAttempts |
2 |
Agent attempts per finding before it fails (both job controllers) |
config.priorityAgingInterval |
24h |
Wait per effective-priority point of aging boost |
config.priorityAgingCap |
25 |
Maximum aging boost |
config.extra |
{} |
Verbatim PATCHY_* keys for every controller; <controller>.config.extra wins over it |
The listen (:8080), health (:8081), and artifact (:9790) addresses are not values — the chart hardcodes them
everywhere they appear (env, container ports, probes, Services, NetworkPolicies).
Per-controller blocks¶
integrationController, sourceController, contextController, investigationController, and remediationController
all share one shape: image, config (+ config.extra), resources, podAnnotations, podLabels, nodeSelector,
tolerations, affinity, serviceAccount.{create,name,annotations}, and networkPolicy.create. A
service.{type,port,nodePort,annotations} block exists only on the two controllers anything dials — integration
(:8080, fronted by webhook.*; NodePort covers the kind/dev flow) and source (:9790, in-cluster only by design).
The controller-specific config defaults:
| Key | Default | Maps to |
|---|---|---|
integrationController.config.accumulationWindow |
1h |
PATCHY_ACCUMULATION_WINDOW |
investigationController.config.findingMinAge |
1h |
PATCHY_FINDING_MIN_AGE |
investigationController.config.maxConcurrentInvestigations |
3 |
PATCHY_MAX_CONCURRENT_INVESTIGATIONS |
investigationController.config.confidenceThreshold |
"0.75" |
PATCHY_CONFIDENCE_THRESHOLD |
remediationController.config.maxConcurrentRemediations |
1 |
PATCHY_MAX_CONCURRENT_REMEDIATIONS |
remediationController.config.findingTTL |
336h |
PATCHY_FINDING_TTL ("0" keeps findings forever) |
Everything else a binary binds (see the configuration reference) is reachable through
config.extra / <controller>.config.extra.
Agent sandbox¶
| Key | Default | Purpose |
|---|---|---|
agent.namespace |
patchy-agents |
Created by the chart with the restricted PSS labels |
agent.createNamespace |
true |
Set false when the namespace is managed elsewhere |
agent.serviceAccount |
patchy-agent |
The Job identity: no Role, token not mounted |
agent.jobDeadline / agent.jobTTL |
1h / 1h |
activeDeadlineSeconds / ttlSecondsAfterFinished |
agent.modelAllowlist |
anthropic/claude-sonnet-5,anthropic/claude-opus-5 |
Canonical model ids the investigation may request for remediation |
agent.investigate.* |
anthropic/claude-sonnet-5 / 15m / 25 / 150000 |
model/timeout/maxTurns/tokenBudget — absolute (harness derived from the model) |
agent.remediate.* |
anthropic/claude-sonnet-5 / 45m / 80 / 400000 |
Same shape; maxTurns/tokenBudget are ceilings the report's requests are clamped to |
agent.runners.<harness> |
claude enabled; codex, copilot disabled | Per-harness enabled/image; codex/copilot add secret/secretKey/secretEnv/hosts/dnsPatterns, claude a provider block instead (brokered) |
agent.runners.claude.provider |
name: anthropic |
Which model API the egress broker fronts: anthropic/bedrock/vertex/foundry + region/regionPrefix/projectID/resource/modelMap/env |
egressBroker.* |
deploys when a claude runner is enabled | The egress credential broker: anthropicSecret, anthropicAuth (key/token), foundrySecret, serviceAccount.annotations (workload identity), networkPolicy.extraEgress |
agent.networkPolicy.create |
true |
Default-deny both directions + DNS + artifact + broker + TCP-443-only egress |
agent.networkPolicy.clusterCIDRs |
RFC-1918 + link-local | Cluster-internal ranges excluded from agent egress |
agent.runners.<harness>.hosts |
codex: api.openai.com; claude: none (brokered) |
Per-runner egress allowlist — deliberately no forge hosts |
agent.networkPolicy.clusterDNSPatterns |
*.svc.cluster.local |
Cilium only: cluster-local names every runner may resolve (for the artifact fetch) |
agent.networkPolicy.mode |
auto |
Hostname-egress dialect: auto/none/cilium/gke/istio — one policy per runner |
agent.networkPolicy.broadEgress |
auto |
Keep the base "443 to anywhere" rule: auto/always/never — see the warning below |
agent.networkPolicy.cilium.enabled |
false |
Deprecated alias for mode: cilium, honoured only while mode is auto |
agent.networkPolicy.istio.enabled |
false |
Deprecated alias for mode: istio, honoured only while mode is auto |
mode: auto reads the cluster's API surface on every render against a live cluster — including every helm-controller
reconcile — and picks gke (GKE's FQDNNetworkPolicy, needs a cluster with --enable-fqdn-network-policy), cilium
(a real Cilium; never selected on GKE, where a CiliumNetworkPolicy enforces nothing) or none. It never selects
istio. An off-cluster helm template sees no cluster and renders none, so pin mode when you need a deterministic
render. Enabling both Cilium and Istio via the deprecated aliases fails the render — pick one.
Because network policies are additive, an FQDN allowlist alongside the base policy's "443 to anywhere" rule
constrains nothing; broadEgress: auto therefore drops that rule whenever a hostname mode is enforcing. See the
isolation model for what each layer requires and what it doesn't
cover.
Model providers (brokered claude)¶
All claude model traffic goes through the egress credential broker, which deploys
automatically whenever a claude runner is enabled. agent.runners.claude.provider picks the API it fronts:
# First-party Anthropic (the default): the API key lives with the broker in
# the RELEASE namespace. anthropicAuth: token sends a `claude setup-token`
# OAuth token as a bearer instead.
egressBroker:
anthropicSecret: patchy-anthropic # key: api-key
# Amazon Bedrock: SigV4-signed with the broker's AWS identity (IRSA here).
agent:
runners:
claude:
provider:
name: bedrock
region: us-east-1 # model ids derive as us.anthropic.<model>
egressBroker:
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/patchy-broker
# GCP Vertex AI: OAuth via GKE Workload Identity.
agent:
runners:
claude:
provider:
name: vertex
region: europe-west1
projectID: my-project
egressBroker:
serviceAccount:
annotations:
iam.gke.io/gcp-service-account: patchy-broker@my-project.iam.gserviceaccount.com
# Microsoft Foundry: deployment names cannot be derived, so the model map is
# mandatory and validated at controller startup against the allowlist.
agent:
runners:
claude:
provider:
name: foundry
resource: my-foundry
modelMap:
anthropic/claude-sonnet-5: my-sonnet-deployment
anthropic/claude-opus-5: my-opus-deployment
egressBroker:
foundrySecret: patchy-foundry # omit for Entra via Azure Workload Identity
Scope the cloud role to Invoke only: bedrock:InvokeModel*, aiplatform.endpoints.predict, Cognitive Services User.
Migrating from a pre-broker release
The claude model credential moves: it used to be patchy-anthropic in the agent namespace, wired into the pod;
now the broker is its only consumer and it lives in the release namespace. Create the Secret in the release
namespace, upgrade the chart (the broker lands), then delete the old Secret from the agent namespace. The
agent.runners.claude.secret* values are ignored (a NOTES warning fires if they are still set); if the old
secretEnv was CLAUDE_CODE_OAUTH_TOKEN, set egressBroker.anthropicAuth: token so the broker sends the OAuth
token as a bearer. Codex and copilot are unaffected.
Operational notes¶
Singletons by design
All five controllers are replicas: 1 with strategy: Recreate; the leader-election Lease is insurance
against a botched rollout, not a scaling mechanism. Do not scale the Deployments.
helm uninstalldeletes the agent namespace (killing any running agent Job) but — withcrds.keep— never the CRDs, so the Findings and the all-time FindingRollup statistics survive a reinstall.- Lint and render locally with
mise run helm-lint. - Chart and images carry build-provenance attestations:
gh attestation verify --owner bitwise-media-group oci://ghcr.io/bitwise-media-group/patchy/charts/patchy:X.Y.Z.