ADR-0058 Architecture decision record
A Harness answers the Orchestrator alone
A red-team review (2026-09-22) found that the Harness wire had no authentication. POST /agents/:name/:id, the stream and history views, and .../abort checked no bearer, and the server bound 0.0.0.0:8080. Iids are derivable (<runId>/<machine>/<agent> for a continued conversation, ADR-0057), so any pod that could reach a Harness could admit a Turn into another conversation, read its history, or abort it. An Agent with bash in one Sandbox could do this to another Sandbox, or to the Instance Harness, which holds every Menu-only conversation in the Instance (ADR-0031). No NetworkPolicy existed to limit the reach. The review also found that the echo (ADR-0023) sent the raw Instance token to every Sandbox Harness on each push. The env held only its digest, but the token itself passed through the process that the Agent executes code beside. So we authenticate the wire with a bearer that each Harness can verify and cannot mint, and we add an ingress NetworkPolicy that admits only the Orchestrator to a Harness pod.
Decision
- One bearer per placement. The Orchestrator presents
harnessToken(key, placement) = HMAC(signing key, "harness:" + placement)on every Harness wire call: admit, each long-poll, abort, and echo.placementis the name of the pod that hosts the Turn: the Sandbox's name, orjr2-instance-harness. This is the same name the registration records as its delivery scope (ADR-0013), so one name scopes both directions. The Custodian there may speak for the Turn's picks, and only this bearer may drive the conversation there. - A key per Effector placement, derived the same way (ADR-0068).
effectorKey(key, placement) = HMAC(signing key, "effector:" + placement)signs every request the Orchestrator sends an Effector and every answer it takes back.placementisinstancefor the Instance Effector and the Sandbox's name for a Sandbox Effector, so each Sandbox pod has its own key and a request signed for one Sandbox is refused by every other; no Sandbox name isinstance, since each one startsws-. Theeffector:prefix keeps it apart from every Harness bearer and Sandbox token. Unlike a Harness, an Effector holds the key itself — it must sign its answers — in a Secret only its own container mounts.jr2 upwrites the Instance Effector's (jr2-instance-effector) from the kept signing key. The Orchestrator writes a Sandbox Effector's at provision, as it writes the Sandbox token: into<sandbox>-effector, a Secret the Sandbox CR owns, so it goes with the pod. Either way the Orchestrator stores nothing and derives the key again after a restart, and a re-provision applies the same value. - The Harness holds only the digest. Its env carries
JR2_HARNESS_TOKEN_SHA256 = sha256(bearer). The Agent can read that env, and a digest inverts to nothing. So a Harness can verify its own bearer, and it can mint none, for itself or for another pod. The HMAC key never leaves the Orchestrator's Secret. - The bearer is derived, not stored. This is the reason the Sandbox token is a signed name (
tokens.ts). The Orchestrator holds no per-pod state. A restarted Orchestrator derives the same bearer that a live Harness already checks, so re-attach (ADR-0012) survives with no change. Theharness:prefix separates it from the Sandbox token: no DNS-label name produces it. - Every route is gated, and the gate runs first. Admit, stream, history, abort and echo answer 401 without the bearer. A 401 comes before a 404, so a refused caller does not learn which conversations exist. A refused admission creates no conversation. The Harness requires
JR2_HARNESS_TOKEN_SHA256at boot and fails loudly without it. - The echo uses the same bearer.
JR2_ECHO_TOKEN_SHA256is removed. The Instance token no longer enters any pod, which is what ADR-0032 and Secrets and trust say. - A refused stream is a fault, not a reconnect. The bearer is derived, so a Harness that refuses it once refuses it on every retry.
waitsettles a 401 or 403 as aSettlementFaultand does not park the Turn forever. - Ingress to every Harness pod comes from the Orchestrator alone.
jr2 upconverges two NetworkPolicies in the instance namespace.jr2-sandbox-ingressselects every pod that has the operator'ssandbox.jr2.dev/namelabel.jr2-instance-harness-ingressselectsapp: jr2-instance-harness. Both admit ingress only from this instance's Orchestrator pods (app: jr2-orchestrator,jr2.dev/instance: <name>). They are converged unconditionally, so a Harness pod is never created before its policy exists. The Instance Effector gets a third of the same form,jr2-instance-effector, applied with it in one List (ADR-0068). - Starting a run needs the Instance token.
POST /workflows/:name/runsmoves fromauthenticatedtoinstanceOnly. The scope of a Sandbox token is its own agent surface. The Custodian's route allowlist does not forward this route, but that is not a control.
Considered options
- Ed25519, one short-lived token per admission, scoped to one iid. This was the first proposal. Rejected.
waitreconnects indefinitely by design, and re-attach after a restart follows a ledgered admission of any age, so a short expiry breaks both unless each poll gets a new token. That adds a public key in every pod and a dependency on clock sync. What it buys over a per-placement bearer is scoping inside one pod. But the Agent already has code execution in that pod's Harness container, so that scope protects nothing. - The Instance token's digest, as the echo used. Rejected. It is one secret for every pod, and it sends the Instance token, the full-trust credential, into each Harness process.
- One Instance-wide Harness token. Rejected. A digest does not let a Harness mint, but the bearer arrives in every Harness process on every call. A per-placement bearer that leaks from one pod opens only that pod, which the Agent there already controls.
- NetworkPolicy alone. Rejected, for the reason ADR-0013 gives: a policy bounds where a pod may talk, and only a token bounds what it may do. The policy cannot separate the Harness from the Custodian in one pod, and a cluster whose CNI does not enforce policy gets nothing from it.
Consequences
- The stub Harness checks nothing. It is a host-side test fixture that is reached by an explicit
endpoint, and a run on the stub path names no placement, so it sends no bearer. This is the second documented divergence from the normative model (ADR-0027). Conformance tests inject the check (checkBearer), andauth.test.tsin@jr2/harnesspins the routing of the gate. - kindnet enforces NetworkPolicy. On the kind version this repo pins, a deny-ingress policy blocks pod-to-pod traffic, so the
@kindtier proves both controls. A Sandbox's own Harness refuses an unauthenticated admission over loopback, where no policy applies. A Sandbox cannot connect to the Instance Harness at all. The second test asserts a transport failure, not a status, because a 401 would mean the bearer stopped it and the policy did not. - Port-forward and probes are not affected.
kubectl port-forwardenters the pod's network namespace through the kubelet, and the kubelet's probes come from the node. On kind, neither is filtered by an ingress policy. So the CLI, the Console, and a human's shell into the User Container (ADR-0005) keep working. If routable access to the User Container is added later (ADR-0005 leaves it open), it must add its own ingress rule. - The
@kindhistory reads present the bearer. The steps derive it from the instance Secret's signing key, as the CLI reads the Instance token: over the kube API, with RBAC as the gate. A human reading a conversation does the same. - Model keys are not in the Harness container.
JR2_PROVIDER_API_KEYand every other held secret live in the Custodian, and the Harness container's env holds their Stand-ins (ADR-0059).jr2 uprefuses a known model key inharness.envor in anenvFromSecret. - Open gaps, recorded here and not fixed:
- Sandbox egress. An Agent with bash can reach the internet and send out anything it can read — which no longer includes a held key: a client that goes around the Custodian carries only Stand-ins. A default-deny egress policy needs an allowlist: DNS, the Orchestrator, the model provider, and whatever the work needs (package registries, for example). That needs a config surface and cluster CIDRs, so it is a separate decision.
- A held key can be spent. The Agent can use a key through the Custodian while its pod lives, and a SigV4 or ADC credential cannot be held at all (ADR-0059).
- The Orchestrator's Secret writes are namespace-wide. Its Role may create and patch every Secret, and may not get or list one (ADR-0068): it applies its own per-Sandbox Secrets and holds no git credential and no Effect secret. RBAC cannot pin
createto a name, andresourceNameshas no prefix form. Two residuals stay, named in ADR-0068: a PATCH answers with the object, so a caller that knows a Secret's name can still read it; and a Role that creates pods (through the Sandbox CR) can mount a Secret into one.