Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions charts/runtime-agent/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ profile (ADR 0069). Readiness means the caches are synced, so the rollout below
returns when the agent is actually collecting rather than when it has started:

kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "runtime-agent.controllerName" . }}
{{- if .Values.collection.enabled }}

To see which objects your filters admitted and which they refused — by name,
with the reason — port-forward the controller's loopback listener (ADR 0084):

kubectl -n {{ .Release.Namespace }} port-forward deployment/{{ include "runtime-agent.controllerName" . }} {{ include "runtime-agent.collectionPort" . }}:{{ include "runtime-agent.collectionPort" . }} &
curl -s localhost:{{ include "runtime-agent.collectionPort" . }}/collection | jq 'select(.collected | not)'

Nothing it answers is ever transmitted. It binds loopback and nothing else, so
a port-forward is the only way in.
{{- end }}
{{- if include "runtime-agent.nodeEnabled" . }}

CHECK ONE THING: the NetworkPolicy needs a CNI that enforces it.
Expand Down
8 changes: 8 additions & 0 deletions charts/runtime-agent/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ the hole in the network policy stays one.
*/}}
{{- define "runtime-agent.healthPort" -}}9090{{- end -}}

{{/*
Where the controller answers which objects the filters decided about. Loopback
and not a value: the address is the whole of what keeps those names inside the
cluster, and the agent refuses any other host anyway (ADR 0084).
*/}}
{{- define "runtime-agent.collectionPort" -}}9091{{- end -}}
{{- define "runtime-agent.collectionAddress" -}}127.0.0.1:{{ include "runtime-agent.collectionPort" . }}{{- end -}}

{{/* The in-cluster base URL of the controller's receiver. */}}
{{- define "runtime-agent.controllerEndpoint" -}}
http://{{ include "runtime-agent.controllerName" . }}.{{ .Release.Namespace }}.svc:8080
Expand Down
5 changes: 5 additions & 0 deletions charts/runtime-agent/templates/controller-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ data:
# The port the probes ask on. Rendered in every profile: the two questions
# a kubelet asks are not a feature of one (ADR 0069).
listenAddress: ":{{ include "runtime-agent.healthPort" . }}"
{{- if .Values.collection.enabled }}
collection:
# Loopback, so a port-forward is the only way in.
listenAddress: {{ include "runtime-agent.collectionAddress" . | quote }}
{{- end }}
nodeIntake:
{{- if include "runtime-agent.nodeEnabled" . }}
enabled: true
Expand Down
8 changes: 8 additions & 0 deletions charts/runtime-agent/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@
"baseURL": {"type": "string"}
}
},
"collection": {
"description": "The loopback listener that names which objects the filters admitted and which they refused (ADR 0084). No address here: loopback is the whole of the control, and the agent refuses any other host.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean"}
}
},
"nodeIntake": {
"type": "object",
"additionalProperties": false,
Expand Down
8 changes: 8 additions & 0 deletions charts/runtime-agent/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ spool:
backend:
baseURL: ""

# The in-cluster listener that answers, by name, which objects your filters
# admitted and which they refused. It binds loopback and nothing else — no
# Service, no container port, no hole in the network policy — so the only way
# to read it is `kubectl port-forward`, whose permission your RBAC already
# defines. What it answers never leaves the cluster (docs/security.md §11).
collection:
enabled: true

nodeIntake:
# The audience the node's projected token requests and the controller
# requires. One value: the chart writes it into both the DaemonSet's token
Expand Down
11 changes: 11 additions & 0 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (

"github.com/RebuildStackCo/runtime-agent/internal/collector"
"github.com/RebuildStackCo/runtime-agent/internal/config"
"github.com/RebuildStackCo/runtime-agent/internal/decisions"
"github.com/RebuildStackCo/runtime-agent/internal/health"
"github.com/RebuildStackCo/runtime-agent/internal/inventory"
"github.com/RebuildStackCo/runtime-agent/internal/journal"
Expand Down Expand Up @@ -1029,6 +1030,16 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
metrics.Handler(sources.gather, logger), logger).Run
}

// The listener that names what the filters decided about, on a loopback
// address of its own. Not a path on the health listener: that port is open
// to the whole cluster by a rule that cannot name the kubelet it exists
// for, and every path on it answers without naming a cluster object
// (ADR 0084).
if addr := cfg.Collection.ListenAddress; addr != "" {
tasks["collection"] = decisions.New(addr,
decisions.Handler(podWatcher.Decisions, podWatcher.Synced, logger), logger).Run
}

// The node-intake receiver is optional (only the ebpf/node profile ships a
// DaemonSet). When enabled it becomes one more lifecycle task, validating
// node tokens locally against the cluster JWKS (ADR 0010) and joining each
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Date: 2026-09-04
Status: Accepted
Amended by: 0070
Amended by: 0070, 0084

## Context

Expand Down
137 changes: 137 additions & 0 deletions docs/adr/0084-what-was-excluded-is-named-inside-the-cluster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# 0084. What was excluded is named, inside the cluster and only there

Date: 2026-09-17

Status: Accepted

Amends: 0069 §1

Adds a second listener to the controller, bound to loopback, answering which
namespaces, pods and Jobs the filters admitted and which they refused, by name
and with the reason. No payload byte changes, no RBAC, no Service, no container
port, and no rule in the shipped NetworkPolicy.

## Context

The agent counts what it excludes and never names it. That is the promise
(CLAUDE.md invariant 6), and ADR 0054 built the coverage payload to keep it: how
many objects each of the customer's five controls removed, never which.

The promise is about what leaves the cluster. It has been read, everywhere it
has been implemented, as though it were about what the agent may *know* — and so
the customer, who owns every one of those annotations and can read them with
`kubectl`, learns from this agent only that nine of something were excluded.

Two questions follow from that and neither has an answer today.

**Someone turned a sensor off and nobody can say which.** An annotation is a
deliberate act by a person with access to an object. Later, a different person
reads a coverage number that moved. Finding the annotation means walking every
namespace, workload and pod by hand, because the object that carries it is
precisely the object nothing reports.

**"Why is my workload not in the report?"** has five possible answers — the
allow list, the deny list, three annotations at three levels — and the agent,
which has already computed the answer for that exact object, states only a
total.

`security.md` §11 promises that "filtering behavior is verifiable from output
rather than only from configuration". What that rests on today is a structured
log line and a set of counters, neither of which can be resolved to an object.

## Decision

**1. The filters answer by name, to a reader inside the cluster.** One line per
object the filter gates: the namespace, the pod, the Job, whether it is
collected, whether it may be profiled, and which control refused it. The pod's
blind spot is there too — a controller of a kind the agent cannot read
(ADR 0028 §3) — which is where a customer finds out that their workload
annotation never had a chance to work.

Nothing is retained to answer this. The informer caches already hold every
namespace, pod and Job whether admitted or not, so the listing is a projection
of live state through the same `Admit*` calls the event path makes, and the
answer cannot drift from the decision. Reading it counts nothing: the counters
describe decisions the agent acted on, and a read is not one (ADR 0054).

**2. Loopback, and any other host is a startup failure.**

Not a path on the health listener, which is where it would otherwise belong.
That port is opened by the shipped NetworkPolicy to **any** source, because the
caller is the kubelet arriving from the node's address and no pod selector in a
template can name it (ADR 0069 §5). A NetworkPolicy selects ports, not paths, so
a path there is reachable by every pod in the cluster — and the rule's own
justification is that every path on that listener "names nothing in the
cluster", which this one does not satisfy.

Two more reasons the policy cannot be the control: it is not rendered at all in
an installation with no node DaemonSet, and where it is rendered it is enforced
by the CNI, so on a cluster whose CNI ignores NetworkPolicy it is inert. The
bind address is the only control that holds in every case.

Loopback also answers authorization without inventing any. The only way in is
`kubectl port-forward`, which Kubernetes already gates on `create` of
`pods/portforward` in the agent's namespace — a permission the customer has
already granted to exactly the people who should be asking. Requiring a
projected token on a shared port would have added a credential, a failure mode
and a request to parse, to arrive at a narrower version of a permission that
already exists.

So `collection.listenAddress` accepts a loopback host or nothing, and refuses
anything else at startup rather than normalizing it. An address that exposed
this listing to every pod in the cluster must not be reachable by a typo.

**3. No query is parsed; the whole list is always answered.** Newline-delimited
JSON, one object per line, so a reader works through it as it arrives. A parameter would be a request this
endpoint acts on, which is the shape invariant 1 exists to refuse, and an
endpoint that filters can return an empty answer that a reader mistakes for
"nothing matched". Selecting is `jq`'s job. While the gating caches are still
filling the answer is a 503 and no names, because a short list here is
indistinguishable from a small cluster.

**4. It never becomes a payload.** This is the one surface that names what the
filters excluded, and what keeps those names inside the cluster is not the
handler — it is that nothing which can transmit knows the package exists. That
is a property of the import graph and is asserted as one.

## Consequences

**Easier.** The customer answers both questions themselves, in one command,
without sending anything to anyone: which object carries the annotation, and
under which control a given workload is missing from the report. The §11 promise
gains a mechanism that resolves to an object. And the support question that
needs a cluster we cannot see is now answerable by the person who has it.

**Harder / given up.** A second listener in the controller, and with it the end
of one-listener-per-role (ADR 0069 §1). The boundary is the property, not the
convenience: the health listener's paths answer without naming anything in the
cluster, and that property is what a rule opening it to the whole cluster rests
on. A path that names objects does not share that port.

Anyone who can port-forward into the agent's namespace reads the deny list,
which ADR 0054 §2 refused to *hash* into a payload. Inside the cluster that is
not the same disclosure — the reader can already list namespaces, and the list
is the customer's own configuration — but it is a real widening of who learns it
easily, and it is stated rather than left to be discovered (ADR 0039).

This covers the objects the controller's filters gate. The node's process scan
has exclusions of its own (ADR 0015) and is not answered here; it would be the
same shape on the node's listener, and is not built until someone asks.

Nothing here changes the inference ADR 0054 named: a workload that disappears
from the collected set as a counter moves can still be identified by
subtraction, by a backend that chooses to. This listing neither closes that nor
widens it — it is not shipped.

**Rejected.** Naming excluded objects in the coverage payload, behind a switch
the operator turns on. An opt-out annotation is an answer, not a question: a
customer who writes one has said what they want, and a tumbler that offers to
send the name treats their decision as ours to bargain with.

A `kubectl` plugin computing the same answer from the same filter code, with the
reader's own credentials. It answers a different question — what the filter
*would* decide, seen through the caller's RBAC — and the question worth
answering is what this agent decided through its own.

This ADR records decisions implemented in the same pull request, per the process
in [`README.md`](README.md).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,4 @@ line to paste.
- [0081. A weakened security default is a build fact like any other](0081-a-weakened-default-is-a-build-fact.md)
- [0082. A confirmed endpoint is named on the port it answered on](0082-a-confirmed-endpoint-is-named-on-its-port.md)
- [0083. The agent names itself in the request it makes](0083-the-agent-names-itself-in-the-request.md)
- [0084. What was excluded is named, inside the cluster and only there](0084-what-was-excluded-is-named-inside-the-cluster.md)
7 changes: 4 additions & 3 deletions docs/prose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ var ceilings = []struct {
// (ADR 0078); a connection that confirmed an endpoint once per image now
// repeats once a minute per process (ADR 0080); twelve more GODEBUG names
// are read out of the build, on a second axis from the two the promise
// named before (ADR 0081); and every request now names the agent that made
// it (ADR 0083).
{"security.md", 1121, 366},
// named before (ADR 0081); every request now names the agent that made it
// (ADR 0083); and a listener answers inside the cluster what the filters
// excluded, by name (ADR 0084).
{"security.md", 1124, 366},
// Each raise names its obligation here too. The body is encoded, so a
// payload's size is no longer the request's (ADR 0076); a window states
// whether it is finished (ADR 0078); gauge samples arrive, where adding
Expand Down
Loading
Loading