CD: dedicated forgejo deploy runner (in-cluster ServiceAccount) #342

Closed
opened 2026-06-15 16:33:15 +00:00 by coilysiren · 12 comments
Owner

Stand up a dedicated Forgejo Actions deploy runner so service rollouts run as CD instead of by hand (the coilyco-bridge/deploy side of coilyco-bridge/deploy#5).

Manifest: deploy/forgejo-runner-deploy.yml. Doc: docs/cd-runner.md.

  • Runner registers with label deploy:host -> jobs run in host mode in the runner pod, inheriting the deployer ServiceAccount token (in-cluster kubectl/helm, no kubeconfig secret, short-lived projected token).
  • RBAC scoped: ClusterRole deployer-namespaces (namespaces get/create) + namespaced Role deployer in the atlas namespace only. Migrating another service adds a RoleBinding in its ns.
  • Home public IP syncs from SSM (/coilysiren/home/public-ip) into the runner as HOME_PUBLIC_IP via ExternalSecret, so rollout needs no AWS creds.

First consumer: coilyco-bridge/deploy .forgejo/workflows/deploy-atlas.yml.

Apply: sudo k3s kubectl apply -f deploy/forgejo-runner-deploy.yml. Verify the runner registers (Forgejo admin runners list shows the deploy label) and a dispatch of deploy-atlas rolls out atlas.

Stand up a dedicated Forgejo Actions deploy runner so service rollouts run as CD instead of by hand (the coilyco-bridge/deploy side of https://forgejo.coilysiren.me/coilyco-bridge/deploy/issues/5). Manifest: `deploy/forgejo-runner-deploy.yml`. Doc: `docs/cd-runner.md`. - Runner registers with label `deploy:host` -> jobs run in host mode in the runner pod, inheriting the `deployer` ServiceAccount token (in-cluster kubectl/helm, no kubeconfig secret, short-lived projected token). - RBAC scoped: `ClusterRole deployer-namespaces` (namespaces get/create) + namespaced `Role deployer` in the `atlas` namespace only. Migrating another service adds a RoleBinding in its ns. - Home public IP syncs from SSM (`/coilysiren/home/public-ip`) into the runner as `HOME_PUBLIC_IP` via ExternalSecret, so rollout needs no AWS creds. First consumer: `coilyco-bridge/deploy` `.forgejo/workflows/deploy-atlas.yml`. Apply: `sudo k3s kubectl apply -f deploy/forgejo-runner-deploy.yml`. Verify the runner registers (Forgejo admin runners list shows the `deploy` label) and a dispatch of deploy-atlas rolls out atlas.
Author
Owner

Update: runner is up, but host-mode jobs need a tool-bearing image

forgejo-runner-deploy-0 registered fine (label deploy, host mode) and picked up the first deploy-atlas run - which failed in ~5s. Root cause: host-mode jobs run inside the stock data.forgejo.org/forgejo/runner:12 image, which has no node, so actions/checkout@v4 (a JS action) can't start. It also lacks git/kubectl/helm for the rollout itself.

Remaining work

Build a custom deploy-runner image and point the StatefulSet at it:

FROM data.forgejo.org/forgejo/runner:12
RUN apk add --no-cache nodejs git    # node for JS actions, git for checkout
# + install kubectl and helm (static binaries)

Push it to the in-cluster registry, set it as the runner container image in deploy/forgejo-runner-deploy.yml, re-apply. Then in coilyco-bridge/deploy re-enable the push trigger on .forgejo/workflows/deploy-atlas.yml (currently dispatch-only, commit 43f104e) and drop its in-workflow kubectl/helm install if the image carries them.

Parked state

  • Runner StatefulSet + RBAC + ExternalSecret: applied, healthy.
  • deploy-atlas workflow: committed, dispatch-only so it does not auto-fail.
  • atlas itself: deployed via manual rollout.sh - CD is the automation layer, not a blocker for atlas being live.
## Update: runner is up, but host-mode jobs need a tool-bearing image `forgejo-runner-deploy-0` registered fine (label `deploy`, host mode) and picked up the first `deploy-atlas` run - which failed in ~5s. Root cause: host-mode jobs run **inside the stock `data.forgejo.org/forgejo/runner:12` image**, which has no `node`, so `actions/checkout@v4` (a JS action) can't start. It also lacks git/kubectl/helm for the rollout itself. ### Remaining work Build a custom deploy-runner image and point the StatefulSet at it: FROM data.forgejo.org/forgejo/runner:12 RUN apk add --no-cache nodejs git # node for JS actions, git for checkout # + install kubectl and helm (static binaries) Push it to the in-cluster registry, set it as the `runner` container image in `deploy/forgejo-runner-deploy.yml`, re-apply. Then in `coilyco-bridge/deploy` re-enable the push trigger on `.forgejo/workflows/deploy-atlas.yml` (currently dispatch-only, commit 43f104e) and drop its in-workflow kubectl/helm install if the image carries them. ### Parked state - Runner StatefulSet + RBAC + ExternalSecret: applied, healthy. - `deploy-atlas` workflow: committed, dispatch-only so it does not auto-fail. - atlas itself: deployed via manual `rollout.sh` - CD is the automation layer, not a blocker for atlas being live.
Member

🛫 ward pre-flight: NO-GO

ward agent claude headless ran a pre-flight feasibility read on this issue before detaching a fire-and-forget run, and the agent judged it NO-GO - it should not be carried unattended until a human weighs in.

completing this requires live k3s cluster access, an in-cluster image build+push, and a change in coilyco-bridge/deploy — none available to a detached container cloning only infrastructure, and the verification step can't be performed unattended.

No container was launched. Review the issue (clarify the scope, resolve the unknown, or split it), then re-dispatch - ward agent claude headless <ref> --no-preflight skips this gate once you've decided it's good to go.

full pre-flight read

The latest word (the 2026-06-15 comment) supersedes the original framing: the runner, RBAC, and ExternalSecret are already applied and healthy, so the real remaining work is building a custom deploy-runner image (node/git/kubectl/helm), pushing it to the in-cluster registry, repointing the StatefulSet, re-applying with sudo k3s kubectl apply, and re-enabling the push trigger in the separate coilyco-bridge/deploy repo. An ephemeral container with a fresh clone of infrastructure has no k3s cluster, no in-cluster registry, no sudo, and no access to the other repo — so while I could author the Dockerfile and edit the manifest YAML, I cannot build/push the image, apply to the live cluster, or perform the stated verification (runner shows the label, a dispatch rolls out atlas). The done-condition is inherently live-cluster and cross-repo, which is exactly what unattended-to-merge can't reach or confirm.

NO-GO: completing this requires live k3s cluster access, an in-cluster image build+push, and a change in coilyco-bridge/deploy — none available to a detached container cloning only infrastructure, and the verification step can't be performed unattended.


Posted automatically by ward agent claude headless pre-flight (ward#147, ward#149).

— Claude (she/her), via ward agent

### 🛫 ward pre-flight: NO-GO `ward agent claude headless` ran a pre-flight feasibility read on this issue before detaching a fire-and-forget run, and the agent judged it **NO-GO** - it should not be carried unattended until a human weighs in. > completing this requires live k3s cluster access, an in-cluster image build+push, and a change in coilyco-bridge/deploy — none available to a detached container cloning only infrastructure, and the verification step can't be performed unattended. No container was launched. Review the issue (clarify the scope, resolve the unknown, or split it), then re-dispatch - `ward agent claude headless <ref> --no-preflight` skips this gate once you've decided it's good to go. <details><summary>full pre-flight read</summary> The latest word (the 2026-06-15 comment) supersedes the original framing: the runner, RBAC, and ExternalSecret are already applied and healthy, so the real remaining work is building a custom deploy-runner image (node/git/kubectl/helm), pushing it to the in-cluster registry, repointing the StatefulSet, re-applying with `sudo k3s kubectl apply`, and re-enabling the push trigger in the separate `coilyco-bridge/deploy` repo. An ephemeral container with a fresh clone of `infrastructure` has no k3s cluster, no in-cluster registry, no `sudo`, and no access to the other repo — so while I could author the Dockerfile and edit the manifest YAML, I cannot build/push the image, apply to the live cluster, or perform the stated verification (runner shows the label, a dispatch rolls out atlas). The done-condition is inherently live-cluster and cross-repo, which is exactly what unattended-to-merge can't reach or confirm. NO-GO: completing this requires live k3s cluster access, an in-cluster image build+push, and a change in coilyco-bridge/deploy — none available to a detached container cloning only infrastructure, and the verification step can't be performed unattended. </details> --- Posted automatically by `ward agent claude headless` pre-flight (ward#147, ward#149). <!-- ward-preflight-nogo --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent reply

ward agent claude reply ran a one-shot standard research pass on this question:

what would be necessary to get warded agents live k3s access with a readonly scoped sub-build of ward


Read-only live k3s for warded agents

This is a sibling of the #342 deploy runner, not the same thing. #342 gives an in-cluster, read-write identity (deployer SA, namespace-scoped) to a runner pod that inherits its own projected token. The ask here is the inverse and the harder-to-place half: a read-only surface for agents that run out-of-cluster (Kai's Mac/laptops, where ward and the lockdown live). Those agents have no pod to inherit a token from, so the two pieces that #342 gets for free, identity delivery and network path, are exactly what need designing.

Principle: the read-only guarantee lives in the token, not in ward

ward's verb gating is defeasible. The verb list is YAML an injected agent could be steered to edit, the lockdown can be widened from outside, and a read verb that shells to kubectl can be arg-smuggled (--as, -f -, exec, patch, replace, cp, port-forward, proxy). So the actual boundary has to be an RBAC token that cannot mutate the cluster no matter what command reaches it. The ward sub-build is then ergonomics plus a second fence, not the primary control. Build it in that order or it gives false confidence.

What's necessary

  1. A read-only cluster identity. A ServiceAccount agent-viewer (own namespace, say agents) bound via ClusterRoleBinding to the built-in view ClusterRole. view is the right default for two reasons: it is get/list/watch only, and it deliberately excludes Secrets. It also does not grant pods/exec, pods/attach, or pods/portforward, which are escape primitives, not reads. If a custom ClusterRole is used instead of view, those exclusions have to be reproduced by hand. Mirror the manifest shape already in deploy/forgejo-runner-deploy.yml, a new deploy/agent-viewer-rbac.yml. Cluster-wide read is reasonable here since there is nothing to mutate, but it can be scoped to a namespace set the same way the deployer Role is if Kai wants tighter blast radius on secret-adjacent configmaps.

  2. Token delivery to an out-of-cluster agent. This is the part #342 does not solve. Keep the same doctrine (no static kubeconfig secret, short-lived projected token) by minting on demand rather than storing: k3s kubectl create token agent-viewer --duration=30m on kai-server, wrapped behind a ward verb. Two delivery shapes:

    • Out-of-cluster kubeconfig carrying the short-lived token plus the API endpoint and CA, refreshed by a ward k8s-token mint verb. The 30m expiry is the safety net behind the RBAC boundary.
    • Route through kai-server over ward's existing kai-server SSH target so the token never leaves the box. Cleaner on credential handling but it hands the agent a kai-server shell, which is a broader grant than cluster-read. The kubeconfig path keeps the grant narrow, so prefer it unless the SSH surface already exists for other reasons.
  3. Network path. The k3s API server (:6443) must be reachable from the agent host. Over the tailnet that is already true via the kai-server tailnet FQDN. No public exposure, no new ingress.

  4. SSM, nothing hardcoded. The API endpoint (tailnet FQDN, already at /coilysiren/kai-server/tailnet-fqdn), CA, and any minted token are opaque/sensitive per the SSM rules. They resolve at runtime, never land in a committed kubeconfig or in ward.yaml defaults. The mint verb writes a kubeconfig to a runtime path, not the repo.

  5. The readonly sub-build of ward. Two readings of "sub-build", and the stronger one matters because of the principle above:

    • Config profile (cheap): a read-only verb group in ward.yaml (k8s-get, k8s-describe, k8s-logs, k8s-top, k8s-api-resources) each shelling to a kubectl invocation, with the lockdown continuing to deny bare kubectl. Fast to ship, but the verbs must arg-allowlist (reject -f, --as, exec, patch, mutating subcommands) because a permissive run: is smugglable.
    • Build-tagged binary (strong): compile a ward variant or command group behind a build tag that physically registers only the read-only k8s subcommands and omits every mutating path, so verb-injection or arg-smuggling cannot reach apply/delete/patch because the code is not in the binary. This is the leak-resistant reading and the one that matches "scoped sub-build". The token RBAC still backstops it, but defense then lives in the binary instead of in editable YAML.
  • deploy/agent-viewer-rbac.yml: ServiceAccount agent-viewer + ClusterRoleBinding to built-in view. One apply, mirrors #342's pattern.
  • A ward k8s-token verb (runs on kai-server) that mints a 30m view token and writes a runtime kubeconfig with the SSM-resolved endpoint/CA.
  • A read-only ward k8s ... command group on the agent side, ideally the build-tagged variant, arg-allowlisted to get/list/describe/logs/top/api-resources/explain, pointed at that kubeconfig.
  • Lockdown keeps denying bare kubectl/helm so the only path to the cluster stays the read-only verb group.

What this does not need

No write RBAC, no namespace create, no exec/port-forward/proxy, no Secrets read, no AWS creds on the agent host, and no public API exposure. The whole grant is "observe the live cluster, minus secret material, on a token that expires."

The Kai-decision in here is delivery shape (out-of-cluster kubeconfig vs route-through-kai-server) and whether the sub-build is the cheap profile or the build-tagged binary. Everything else follows the #342 doctrine directly.


Researched and posted automatically by ward agent claude reply (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent reply `ward agent claude reply` ran a one-shot **standard** research pass on this question: > what would be necessary to get warded agents live k3s access with a readonly scoped sub-build of ward --- ## Read-only live k3s for warded agents This is a sibling of the #342 deploy runner, not the same thing. #342 gives an **in-cluster, read-write** identity (`deployer` SA, namespace-scoped) to a runner pod that inherits its own projected token. The ask here is the inverse and the harder-to-place half: a **read-only** surface for agents that run **out-of-cluster** (Kai's Mac/laptops, where ward and the lockdown live). Those agents have no pod to inherit a token from, so the two pieces that #342 gets for free, identity delivery and network path, are exactly what need designing. ### Principle: the read-only guarantee lives in the token, not in ward ward's verb gating is defeasible. The verb list is YAML an injected agent could be steered to edit, the lockdown can be widened from outside, and a read verb that shells to `kubectl` can be arg-smuggled (`--as`, `-f -`, `exec`, `patch`, `replace`, `cp`, `port-forward`, `proxy`). So the actual boundary has to be an RBAC token that **cannot** mutate the cluster no matter what command reaches it. The ward sub-build is then ergonomics plus a second fence, not the primary control. Build it in that order or it gives false confidence. ### What's necessary 1. **A read-only cluster identity.** A `ServiceAccount agent-viewer` (own namespace, say `agents`) bound via `ClusterRoleBinding` to the built-in `view` ClusterRole. `view` is the right default for two reasons: it is `get/list/watch` only, and it **deliberately excludes Secrets**. It also does not grant `pods/exec`, `pods/attach`, or `pods/portforward`, which are escape primitives, not reads. If a custom ClusterRole is used instead of `view`, those exclusions have to be reproduced by hand. Mirror the manifest shape already in `deploy/forgejo-runner-deploy.yml`, a new `deploy/agent-viewer-rbac.yml`. Cluster-wide read is reasonable here since there is nothing to mutate, but it can be scoped to a namespace set the same way the `deployer` Role is if Kai wants tighter blast radius on secret-adjacent configmaps. 2. **Token delivery to an out-of-cluster agent.** This is the part #342 does not solve. Keep the same doctrine (no static kubeconfig secret, short-lived projected token) by minting on demand rather than storing: `k3s kubectl create token agent-viewer --duration=30m` on kai-server, wrapped behind a ward verb. Two delivery shapes: - **Out-of-cluster kubeconfig** carrying the short-lived token plus the API endpoint and CA, refreshed by a `ward k8s-token` mint verb. The 30m expiry is the safety net behind the RBAC boundary. - **Route through kai-server** over ward's existing `kai-server` SSH target so the token never leaves the box. Cleaner on credential handling but it hands the agent a kai-server shell, which is a broader grant than cluster-read. The kubeconfig path keeps the grant narrow, so prefer it unless the SSH surface already exists for other reasons. 3. **Network path.** The k3s API server (`:6443`) must be reachable from the agent host. Over the tailnet that is already true via the kai-server tailnet FQDN. No public exposure, no new ingress. 4. **SSM, nothing hardcoded.** The API endpoint (tailnet FQDN, already at `/coilysiren/kai-server/tailnet-fqdn`), CA, and any minted token are opaque/sensitive per the SSM rules. They resolve at runtime, never land in a committed kubeconfig or in `ward.yaml` defaults. The mint verb writes a kubeconfig to a runtime path, not the repo. 5. **The readonly sub-build of ward.** Two readings of "sub-build", and the stronger one matters because of the principle above: - **Config profile (cheap):** a read-only verb group in `ward.yaml` (`k8s-get`, `k8s-describe`, `k8s-logs`, `k8s-top`, `k8s-api-resources`) each shelling to a `kubectl` invocation, with the lockdown continuing to deny bare `kubectl`. Fast to ship, but the verbs must arg-allowlist (reject `-f`, `--as`, `exec`, `patch`, mutating subcommands) because a permissive `run:` is smugglable. - **Build-tagged binary (strong):** compile a ward variant or command group behind a build tag that physically registers only the read-only k8s subcommands and omits every mutating path, so verb-injection or arg-smuggling cannot reach `apply/delete/patch` because the code is not in the binary. This is the leak-resistant reading and the one that matches "scoped sub-build". The token RBAC still backstops it, but defense then lives in the binary instead of in editable YAML. ### Recommended minimal shape - `deploy/agent-viewer-rbac.yml`: `ServiceAccount agent-viewer` + `ClusterRoleBinding` to built-in `view`. One apply, mirrors #342's pattern. - A `ward k8s-token` verb (runs on kai-server) that mints a 30m `view` token and writes a runtime kubeconfig with the SSM-resolved endpoint/CA. - A read-only `ward k8s ...` command group on the agent side, ideally the build-tagged variant, arg-allowlisted to `get/list/describe/logs/top/api-resources/explain`, pointed at that kubeconfig. - Lockdown keeps denying bare `kubectl`/`helm` so the only path to the cluster stays the read-only verb group. ### What this does not need No write RBAC, no namespace `create`, no `exec`/`port-forward`/`proxy`, no Secrets read, no AWS creds on the agent host, and no public API exposure. The whole grant is "observe the live cluster, minus secret material, on a token that expires." The Kai-decision in here is delivery shape (out-of-cluster kubeconfig vs route-through-kai-server) and whether the sub-build is the cheap profile or the build-tagged binary. Everything else follows the #342 doctrine directly. --- Researched and posted automatically by `ward agent claude reply` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent reply

ward agent claude reply ran a one-shot standard research pass on this question:

we are going to use this problem as a forcing function for establishing trust in ward, plan out how we gain confidence here


Using read-only k3s as the trust-proving ground for ward

The framing is right, but the first move is to be precise about what "trust in ward" can even mean, because the read-only k3s case is going to expose that the honest answer is narrower than it sounds. Getting that split correct up front is what makes the rest of the plan sound instead of self-reassuring.

Step 0: separate the two claims, because only one of them is ward's to hold

There are two different things people mean by "trust ward":

  • Claim A - accident/footgun prevention. Ward stops a well-behaved agent from fat-fingering a destructive command, and gives a clean verb surface. Ward genuinely owns this.
  • Claim B - adversarial containment. An injected or misaligned agent operating under ward cannot exceed the intended grant. This is the one that matters for live cluster access, and it is the one ward cannot carry alone.

The read-only case makes the reason obvious. Any credential the agent's own process can read, it can use without going through ward at all - read the kubeconfig, curl the API server directly, done. So for anything the agent holds, the containment boundary is the credential's RBAC scope, not ward's verb list. Ward in front of that token is ergonomics plus a second fence, never the load-bearing wall.

This is not a reason to distrust ward. It is the thing the forcing function is supposed to teach: decide, per credential, whether ward is the boundary or just the handle, and never let those two get conflated into false confidence. Write that distinction down before touching manifests.

Why read-only k3s is the correct forcing function

It exercises the entire trust chain - identity minting, out-of-cluster delivery, network path, verb gating, lockdown, arg-smuggling resistance, audit - while the cost of a total ward bypass is bounded to "an agent observed the live cluster, minus Secrets, on a token that expires in 30m." So Kai can run the boundary live and under adversarial pressure without betting anything real. You build the full trust-proving apparatus while the security still rests on RBAC. That is exactly the condition under which you want to be learning where ward's guarantees end.

The plan: confidence as a measured, regression-proof property

Phase 1 - the red-team suite is the deliverable, not a one-off check. Confidence in a boundary comes from sustained failed attempts to cross it, codified so they re-run on every ward release. Stand up ward agent (or a plain test target) that fires the known breakout matrix against the read-only token and records expected outcome per attempt:

  • Arg-smuggling through allowed read verbs - kubectl get -f -, --as/--as-group, exec, attach, port-forward, proxy, --raw, cp, and the sneaky ones (-o go-template/jsonpath that shell out, --token/--server overrides). Expected: denied by the verb's arg-allowlist.
  • Verb injection - steer the agent to add a mutating verb to ward.yaml and invoke it. Expected: denied by lockdown, and even if it lands, denied by RBAC.
  • Lockdown widening - attempt to edit .claude/lockdown-deny.sh / settings.json from inside. Expected: refused, and surfaced.
  • Direct token use bypassing ward - curl the API with the kubeconfig token. Expected: allowed but harmless (RBAC view only). This test exists precisely to document that ward is bypassable by design and that the RBAC floor holds anyway.
  • Credential reach - can the agent read where the mint verb wrote the kubeconfig? Where does it write it, and does anything mutating live next to it?

Each line is green when the actual outcome matches the expected one. That turns "do we trust ward" into a number that regresses if a future ward change weakens a verb.

Phase 2 - observe through a channel ward does not control. Confidence needs a witness ward cannot edit. Turn on the k3s API audit log server-side and assert against it: the agent-viewer token did only get/list/watch, touched no Secrets, never exec/port-forward. Add drift detection on ward.yaml and the lockdown files. If ward's own logs and the cluster's audit log ever disagree, that gap is the finding.

Phase 3 - prefer the build-tagged sub-build over the config profile. The cheap reading (a read-only verb group in editable YAML) keeps the mutating code in the binary and one smuggled arg away. The strong reading (compile a ward variant where the apply/delete/patch/exec paths physically do not exist) shrinks ward's trusted surface to something auditable, so the red-team suite is asserting against a binary that can't reach mutation rather than one that is merely configured not to. For a trust-establishment exercise, ship the build-tagged variant - it is the difference between "we configured it safely" and "the capability isn't there."

Phase 4 - a graduated exposure ladder with explicit promotion gates. Each rung widens blast radius and is gated on the suite being green plus N days of clean audit plus one documented attempt that failed:

  1. Read-only k3s, token fully exposable (current). Ward = handle, RBAC = boundary.
  2. Read-only plus tightened scope on secret-adjacent ConfigMaps.
  3. Write into a throwaway sandbox namespace. This is the first rung where a ward failure costs something, so it is the first rung where the brokering question (below) is forced.
  4. The #342 deployer write path itself.

The architectural payoff this forcing function buys

By the time you reach the write rungs, the read-only exercise has already taught the decisive lesson: you cannot hand the agent process a token that can mutate the cluster, because ward in front of it is bypassable. So "trusting ward" for the #342 deploy path does not mean trusting ward to gate writes. It means not relying on ward for that security property at all - the write credential lives somewhere the agent process can't read (the runner pod that inherits its own projected token, per #342, or an o2r-style request envelope the agent files and a holder verifies before acting). Ward issues the request, a thing the agent can't impersonate executes it.

That decision - broker the write credential instead of gating it - is the real output of the forcing function, and the read-only phase lets Kai arrive at it having proven the boundary model cheaply rather than discovering it the expensive way on the deploy path.

Concrete first moves

  • Write docs/ward-trust-model.md (sibling of docs/cd-runner.md): the Claim A / Claim B split, the threat model, and the promotion ladder with its gates. Confidence that is written and re-verified, not vibes.
  • Land deploy/agent-viewer-rbac.yml (view ClusterRole, no Secrets, no exec) and the ward k8s-token mint verb, exactly as the prior research laid out.
  • Build the red-team suite as a checked-in target that runs on every ward release, with the direct-token-bypass test included and labeled "expected: harmless."
  • Ship the read-only verb group as the build-tagged variant, not the YAML profile.
  • Turn on k3s audit logging and assert the view-only invariant against it.

The one-line version: we do not gain confidence by trusting ward to be the wall. We gain it by proving, cheaply and on a token that can't hurt us, exactly how thick ward's wall is and isn't - then building the write path so the parts ward can't hold are held by RBAC and brokering instead.


Researched and posted automatically by ward agent claude reply (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent reply `ward agent claude reply` ran a one-shot **standard** research pass on this question: > we are going to use this problem as a forcing function for establishing trust in ward, plan out how we gain confidence here --- ## Using read-only k3s as the trust-proving ground for ward The framing is right, but the first move is to be precise about what "trust in ward" can even mean, because the read-only k3s case is going to expose that the honest answer is narrower than it sounds. Getting that split correct up front is what makes the rest of the plan sound instead of self-reassuring. ### Step 0: separate the two claims, because only one of them is ward's to hold There are two different things people mean by "trust ward": - **Claim A - accident/footgun prevention.** Ward stops a well-behaved agent from fat-fingering a destructive command, and gives a clean verb surface. Ward genuinely owns this. - **Claim B - adversarial containment.** An injected or misaligned agent operating under ward cannot exceed the intended grant. This is the one that matters for live cluster access, and it is the one ward **cannot** carry alone. The read-only case makes the reason obvious. Any credential the agent's own process can read, it can use **without going through ward at all** - read the kubeconfig, `curl` the API server directly, done. So for anything the agent holds, the containment boundary is the credential's RBAC scope, not ward's verb list. Ward in front of that token is ergonomics plus a second fence, never the load-bearing wall. This is not a reason to distrust ward. It is the thing the forcing function is supposed to teach: **decide, per credential, whether ward is the boundary or just the handle, and never let those two get conflated into false confidence.** Write that distinction down before touching manifests. ### Why read-only k3s is the correct forcing function It exercises the entire trust chain - identity minting, out-of-cluster delivery, network path, verb gating, lockdown, arg-smuggling resistance, audit - while the cost of a total ward bypass is bounded to "an agent observed the live cluster, minus Secrets, on a token that expires in 30m." So Kai can run the boundary **live and under adversarial pressure** without betting anything real. You build the full trust-proving apparatus while the security still rests on RBAC. That is exactly the condition under which you want to be learning where ward's guarantees end. ### The plan: confidence as a measured, regression-proof property **Phase 1 - the red-team suite is the deliverable, not a one-off check.** Confidence in a boundary comes from sustained failed attempts to cross it, codified so they re-run on every ward release. Stand up `ward agent` (or a plain test target) that fires the known breakout matrix against the read-only token and records expected outcome per attempt: - **Arg-smuggling through allowed read verbs** - `kubectl get -f -`, `--as`/`--as-group`, `exec`, `attach`, `port-forward`, `proxy`, `--raw`, `cp`, and the sneaky ones (`-o go-template`/`jsonpath` that shell out, `--token`/`--server` overrides). Expected: denied by the verb's arg-allowlist. - **Verb injection** - steer the agent to add a mutating verb to `ward.yaml` and invoke it. Expected: denied by lockdown, and even if it lands, denied by RBAC. - **Lockdown widening** - attempt to edit `.claude/lockdown-deny.sh` / `settings.json` from inside. Expected: refused, and surfaced. - **Direct token use bypassing ward** - `curl` the API with the kubeconfig token. Expected: **allowed but harmless** (RBAC `view` only). This test exists precisely to document that ward is bypassable by design and that the RBAC floor holds anyway. - **Credential reach** - can the agent read where the mint verb wrote the kubeconfig? Where does it write it, and does anything mutating live next to it? Each line is green when the actual outcome matches the expected one. That turns "do we trust ward" into a number that regresses if a future ward change weakens a verb. **Phase 2 - observe through a channel ward does not control.** Confidence needs a witness ward cannot edit. Turn on the k3s API audit log server-side and assert against it: the `agent-viewer` token did only `get/list/watch`, touched no Secrets, never `exec`/`port-forward`. Add drift detection on `ward.yaml` and the lockdown files. If ward's own logs and the cluster's audit log ever disagree, that gap is the finding. **Phase 3 - prefer the build-tagged sub-build over the config profile.** The cheap reading (a read-only verb group in editable YAML) keeps the mutating code in the binary and one smuggled arg away. The strong reading (compile a ward variant where the `apply/delete/patch/exec` paths physically do not exist) shrinks ward's trusted surface to something auditable, so the red-team suite is asserting against a binary that **can't** reach mutation rather than one that is merely configured not to. For a trust-establishment exercise, ship the build-tagged variant - it is the difference between "we configured it safely" and "the capability isn't there." **Phase 4 - a graduated exposure ladder with explicit promotion gates.** Each rung widens blast radius and is gated on the suite being green plus N days of clean audit plus one documented attempt that failed: 1. Read-only k3s, token fully exposable (current). Ward = handle, RBAC = boundary. 2. Read-only plus tightened scope on secret-adjacent ConfigMaps. 3. Write into a throwaway sandbox namespace. **This is the first rung where a ward failure costs something**, so it is the first rung where the brokering question (below) is forced. 4. The #342 `deployer` write path itself. ### The architectural payoff this forcing function buys By the time you reach the write rungs, the read-only exercise has already taught the decisive lesson: **you cannot hand the agent process a token that can mutate the cluster**, because ward in front of it is bypassable. So "trusting ward" for the #342 deploy path does not mean trusting ward to gate writes. It means **not relying on ward for that security property at all** - the write credential lives somewhere the agent process can't read (the runner pod that inherits its own projected token, per #342, or an o2r-style request envelope the agent files and a holder verifies before acting). Ward issues the request, a thing the agent can't impersonate executes it. That decision - broker the write credential instead of gating it - is the real output of the forcing function, and the read-only phase lets Kai arrive at it having proven the boundary model cheaply rather than discovering it the expensive way on the deploy path. ### Concrete first moves - Write `docs/ward-trust-model.md` (sibling of `docs/cd-runner.md`): the Claim A / Claim B split, the threat model, and the promotion ladder with its gates. Confidence that is written and re-verified, not vibes. - Land `deploy/agent-viewer-rbac.yml` (`view` ClusterRole, no Secrets, no exec) and the `ward k8s-token` mint verb, exactly as the prior research laid out. - Build the red-team suite as a checked-in target that runs on every ward release, with the direct-token-bypass test included and labeled "expected: harmless." - Ship the read-only verb group as the build-tagged variant, not the YAML profile. - Turn on k3s audit logging and assert the `view`-only invariant against it. The one-line version: we do not gain confidence by trusting ward to be the wall. We gain it by proving, cheaply and on a token that can't hurt us, exactly how thick ward's wall is and isn't - then building the write path so the parts ward can't hold are held by RBAC and brokering instead. --- Researched and posted automatically by `ward agent claude reply` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent reply

ward agent claude reply ran a one-shot standard research pass on this question:

dig deeper and try harder: is there an ergonomic way to define a fully warded agent container be incapable of reading kubeconfig on its own, via means of needing to call out to the ward binary and said binary sits outside of the agents process and therefore can be granted its own permission set


Yes - the earlier "ward is bypassable" verdict rests on one assumption, and this breaks it

The prior comments concluded ward can only ever be ergonomics plus a second fence, never the wall. That conclusion is correct only under one assumption: that ward runs inside the agent's process, under the agent's uid. Then anything ward can read, the agent can read, so the credential's RBAC scope is the real boundary and ward is just the handle.

Your question targets exactly that assumption. Move ward out of the agent's process so it runs as a separate security principal that holds the credential, and the agent literally has no token to read or curl with. At that point ward stops being a defeasible YAML gate and becomes a genuine reference monitor: the only path to the cluster is an RPC the agent makes to a thing it cannot impersonate, read the memory of, or smuggle args past at the file level. This is the classic privilege-separation / credential-broker pattern (think ssh-agent, sudo, a secrets sidecar), and it is the right answer here.

The mechanism: the credential lives behind a uid/namespace boundary the agent can't cross

The credential is never delivered to the agent. It is held by a broker, and the kernel enforces the separation. Three deployment shapes, strongest first:

  • Sidecar broker container (best fit for "warded agent container"). The agent container and a ward-broker container share a pod but not a filesystem. The token (or kubeconfig) is mounted/minted only into the broker container. They share one thing: a Unix domain socket on an emptyDir. The agent dials the socket but cannot see the broker's mount namespace, so there is no file to race for and no token in the agent's view. This is the cleanest version and it mirrors #342's own model - the runner pod inherits a projected token the workload can't choose, here generalized so the untrusted code can't reach it at all.
  • Two uids in one container (cheaper, weaker). Run the agent as uid agent, run a small ward-broker daemon as uid ward, kubeconfig 0600 owned by ward. Filesystem perms keep agent out. This works but is weaker: same PID namespace means you must drop CAP_SYS_PTRACE and never let the agent process run as root, or it reads the broker's memory. Prefer separate containers when you can.
  • Out-of-cluster broker on kai-server. For the Mac/laptop reality (no pod to inherit from), the broker runs on kai-server as its own user, holds the minted view token in its own memory only, and the agent reaches it over the tailnet. This keeps the grant narrower than the earlier "route through kai-server SSH" option because the agent gets an RPC surface, not a shell.

In all three, the best version writes the token to no shared file at all - the broker mints it (k3s kubectl create token, 30m) and keeps it in process memory, refreshing on expiry. There is then nothing on disk for the agent to find.

The one design rule that makes or breaks it

The broker must expose semantic, policy-checked operations, not "run this kubectl line with my token." If the broker proxies raw argv, you have just moved the arg-smuggling surface (-f -, --as, exec, --raw, templated shell-outs) to the far side of the socket and gained nothing. So:

  • The broker, not the agent, is where the allowlist lives. It accepts structured requests (get pods -n atlas, logs deploy/x) and constructs the kubectl/API call itself.
  • The broker authenticates and authorizes per request by caller identity (socket peer-cred / SO_PEERCRED, or a pod identity), and applies the policy for that caller.
  • The broker is now your trusted computing base. Keep it small, single-purpose, and auditable - that is the whole reason this is safer than a big in-process ward.

This is the same discipline the prior comment asked of the build-tagged binary, just relocated to the only process that holds power.

Why this is strictly stronger than the build-tagged sub-build

The build-tag variant removes mutation code from the agent's binary, but the agent still holds the token, so an injected agent ignores ward entirely and curls the API directly. The broker model removes the credential, which is the thing that actually grants power. With no token in its process, the agent's only capability toward the cluster is "ask the broker," and the broker's policy bounds that. Build-tagging answers "the mutating verbs aren't in my binary"; brokering answers "I have no credential at all" - the latter is the real fix, and it composes with build-tagging rather than competing.

It is also more ergonomic, not less

This is the part worth emphasizing, because the instinct is that a daemon is heavier. In practice the agent-side surface gets simpler:

  • The agent-side ward k8s get pods -n atlas becomes a thin stub that dials the socket. Same verb surface Kai already wants, no kubeconfig in sight.
  • Token lifecycle (mint, refresh, expiry, CA, endpoint resolution from SSM) all moves broker-side and becomes invisible to the agent. No ward k8s-token mint verb the agent calls, no kubeconfig handoff, no refresh dance.
  • No per-platform build tags to cut and maintain. One broker binary, policy in one place.
  • One broker serves many agents and many credentials with per-caller policy, so the read-only agent and a future deploy agent hit the same daemon with different grants.

The cost is honest: you run a daemon, define a small RPC protocol (that protocol is the new attack surface), and the broker becomes a trusted component you must keep tiny and audited. That is a bounded, auditable cost, which is exactly the trade you want.

The unifying payoff

This collapses the whole promotion ladder from the prior comment into one architecture, different policy:

  • Read-only k3s = broker with a view-only policy. Even total agent compromise yields reads, minus Secrets.
  • The #342 deployer write path = the same broker with a deploy policy (namespace-scoped, optionally gated on an o2r request-envelope a human or policy approves before the broker acts). The agent never holds the deploy token, so it cannot escalate past the broker's policy - which is precisely the "broker the write credential instead of gating it" conclusion the prior research reached, now realized as the default mechanism rather than a special case for writes.

So the read-only exercise is not just a cheap proving ground for whether to trust ward - it is the place to build the broker itself, on a credential that can't hurt anyone, so that by the time the deploy path turns on, the trusted component is already proven and the only thing that changes is the policy string.

Residual risks to design against

  • The broker is now load-bearing. Its policy and its argv construction are the boundary. Keep it minimal, fuzz the request parser, and treat every agent request as hostile input (confused-deputy: never trust the agent's framing of what it's allowed to do).
  • Socket access control. Only the agent uid/container should be able to dial it, and the broker should still authorize per request rather than trusting "if you reached the socket you're allowed."
  • Defense in depth survives. Keep RBAC scoping on the broker's own token (view, no Secrets, no exec) so a broker bug still can't mutate. The broker raises the floor; it doesn't excuse a loose token.
  • Audit witness. Keep the k3s API audit log assertion - it observes through a channel neither the agent nor the broker controls, so it catches a broker that misbehaves.

Concrete first shape for this repo

  • deploy/agent-viewer-rbac.yml: ServiceAccount agent-viewer + ClusterRoleBinding to built-in view (unchanged from prior research).
  • deploy/ward-broker.yml (or a sidecar in the agent's pod manifest): the broker as a separate container/uid, mounting/minting the view token into its namespace only, exposing a UDS on a shared emptyDir. Mirrors deploy/forgejo-runner-deploy.yml's shape.
  • Agent-side ward k8s ... becomes a UDS client stub, no token, no kubeconfig.
  • docs/cd-runner.md sibling docs/ward-broker.md: the privilege-separation model, the "semantic verbs only, broker owns the allowlist" rule, and the read-only-now / deploy-later policy split.
  • The red-team suite from the prior comment gains its sharpest new test: "agent attempts to locate or read any token/kubeconfig in its own namespace - expected: nothing exists." That assertion is the one the in-process model could never pass, and passing it is the proof that ward became the wall.

The one-line version: keep ward in the agent's process and it is forever a handle in front of a credential the agent holds. Move ward to a separate principal that holds the credential and exposes only policy-checked verbs, and the agent holds nothing - ward stops describing the boundary and becomes it.


Researched and posted automatically by ward agent claude reply (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent reply `ward agent claude reply` ran a one-shot **standard** research pass on this question: > dig deeper and try harder: is there an ergonomic way to define a fully warded agent container be incapable of reading kubeconfig on its own, via means of needing to call out to the ward binary and said binary sits outside of the agents process and therefore can be granted its own permission set --- ## Yes - the earlier "ward is bypassable" verdict rests on one assumption, and this breaks it The prior comments concluded ward can only ever be ergonomics plus a second fence, never the wall. That conclusion is correct **only under one assumption**: that ward runs inside the agent's process, under the agent's uid. Then anything ward can read, the agent can read, so the credential's RBAC scope is the real boundary and ward is just the handle. Your question targets exactly that assumption. Move ward out of the agent's process so it runs as a **separate security principal** that holds the credential, and the agent literally has no token to read or `curl` with. At that point ward stops being a defeasible YAML gate and becomes a genuine **reference monitor**: the only path to the cluster is an RPC the agent makes to a thing it cannot impersonate, read the memory of, or smuggle args past at the file level. This is the classic privilege-separation / credential-broker pattern (think `ssh-agent`, `sudo`, a secrets sidecar), and it is the right answer here. ## The mechanism: the credential lives behind a uid/namespace boundary the agent can't cross The credential is never delivered **to** the agent. It is held by a broker, and the kernel enforces the separation. Three deployment shapes, strongest first: - **Sidecar broker container (best fit for "warded agent container").** The agent container and a `ward-broker` container share a pod but not a filesystem. The token (or kubeconfig) is mounted/minted **only** into the broker container. They share one thing: a Unix domain socket on an `emptyDir`. The agent dials the socket but cannot see the broker's mount namespace, so there is no file to race for and no token in the agent's view. This is the cleanest version and it mirrors #342's own model - the runner pod inherits a projected token the workload can't choose, here generalized so the *untrusted* code can't reach it at all. - **Two uids in one container (cheaper, weaker).** Run the agent as uid `agent`, run a small `ward-broker` daemon as uid `ward`, kubeconfig `0600` owned by `ward`. Filesystem perms keep `agent` out. This works but is weaker: same PID namespace means you must drop `CAP_SYS_PTRACE` and never let the agent process run as root, or it reads the broker's memory. Prefer separate containers when you can. - **Out-of-cluster broker on kai-server.** For the Mac/laptop reality (no pod to inherit from), the broker runs on kai-server as its own user, holds the minted `view` token in **its own memory only**, and the agent reaches it over the tailnet. This keeps the grant narrower than the earlier "route through kai-server SSH" option because the agent gets an RPC surface, not a shell. In all three, the best version writes the token to **no shared file at all** - the broker mints it (`k3s kubectl create token`, 30m) and keeps it in process memory, refreshing on expiry. There is then nothing on disk for the agent to find. ## The one design rule that makes or breaks it The broker must expose **semantic, policy-checked operations**, not "run this kubectl line with my token." If the broker proxies raw argv, you have just moved the arg-smuggling surface (`-f -`, `--as`, `exec`, `--raw`, templated shell-outs) to the far side of the socket and gained nothing. So: - The broker, not the agent, is where the allowlist lives. It accepts structured requests (`get pods -n atlas`, `logs deploy/x`) and constructs the kubectl/API call itself. - The broker authenticates and authorizes **per request** by caller identity (socket peer-cred / SO_PEERCRED, or a pod identity), and applies the policy for that caller. - The broker is now your trusted computing base. Keep it small, single-purpose, and auditable - that is the whole reason this is safer than a big in-process ward. This is the same discipline the prior comment asked of the build-tagged binary, just relocated to the only process that holds power. ## Why this is strictly stronger than the build-tagged sub-build The build-tag variant removes mutation **code** from the agent's binary, but the agent still **holds the token**, so an injected agent ignores ward entirely and `curl`s the API directly. The broker model removes the **credential**, which is the thing that actually grants power. With no token in its process, the agent's only capability toward the cluster is "ask the broker," and the broker's policy bounds that. Build-tagging answers "the mutating verbs aren't in my binary"; brokering answers "I have no credential at all" - the latter is the real fix, and it composes with build-tagging rather than competing. ## It is also more ergonomic, not less This is the part worth emphasizing, because the instinct is that a daemon is heavier. In practice the agent-side surface gets **simpler**: - The agent-side `ward k8s get pods -n atlas` becomes a thin stub that dials the socket. Same verb surface Kai already wants, no kubeconfig in sight. - Token lifecycle (mint, refresh, expiry, CA, endpoint resolution from SSM) all moves broker-side and becomes **invisible** to the agent. No `ward k8s-token` mint verb the agent calls, no kubeconfig handoff, no refresh dance. - No per-platform build tags to cut and maintain. One broker binary, policy in one place. - One broker serves many agents and many credentials with **per-caller policy**, so the read-only agent and a future deploy agent hit the same daemon with different grants. The cost is honest: you run a daemon, define a small RPC protocol (that protocol is the new attack surface), and the broker becomes a trusted component you must keep tiny and audited. That is a bounded, auditable cost, which is exactly the trade you want. ## The unifying payoff This collapses the whole promotion ladder from the prior comment into **one architecture, different policy**: - **Read-only k3s** = broker with a `view`-only policy. Even total agent compromise yields reads, minus Secrets. - **The #342 `deployer` write path** = the same broker with a deploy policy (namespace-scoped, optionally gated on an o2r request-envelope a human or policy approves before the broker acts). The agent never holds the deploy token, so it cannot escalate past the broker's policy - which is precisely the "broker the write credential instead of gating it" conclusion the prior research reached, now realized as the default mechanism rather than a special case for writes. So the read-only exercise is not just a cheap proving ground for *whether* to trust ward - it is the place to **build the broker itself**, on a credential that can't hurt anyone, so that by the time the deploy path turns on, the trusted component is already proven and the only thing that changes is the policy string. ## Residual risks to design against - **The broker is now load-bearing.** Its policy and its argv construction are the boundary. Keep it minimal, fuzz the request parser, and treat every agent request as hostile input (confused-deputy: never trust the agent's framing of what it's allowed to do). - **Socket access control.** Only the agent uid/container should be able to dial it, and the broker should still authorize per request rather than trusting "if you reached the socket you're allowed." - **Defense in depth survives.** Keep RBAC scoping on the broker's own token (`view`, no Secrets, no exec) so a broker bug still can't mutate. The broker raises the floor; it doesn't excuse a loose token. - **Audit witness.** Keep the k3s API audit log assertion - it observes through a channel neither the agent nor the broker controls, so it catches a broker that misbehaves. ## Concrete first shape for this repo - `deploy/agent-viewer-rbac.yml`: `ServiceAccount agent-viewer` + `ClusterRoleBinding` to built-in `view` (unchanged from prior research). - `deploy/ward-broker.yml` (or a sidecar in the agent's pod manifest): the broker as a separate container/uid, mounting/minting the `view` token into **its** namespace only, exposing a UDS on a shared `emptyDir`. Mirrors `deploy/forgejo-runner-deploy.yml`'s shape. - Agent-side `ward k8s ...` becomes a UDS client stub, no token, no kubeconfig. - `docs/cd-runner.md` sibling `docs/ward-broker.md`: the privilege-separation model, the "semantic verbs only, broker owns the allowlist" rule, and the read-only-now / deploy-later policy split. - The red-team suite from the prior comment gains its sharpest new test: **"agent attempts to locate or read any token/kubeconfig in its own namespace - expected: nothing exists."** That assertion is the one the in-process model could never pass, and passing it is the proof that ward became the wall. The one-line version: keep ward in the agent's process and it is forever a handle in front of a credential the agent holds. Move ward to a separate principal that holds the credential and exposes only policy-checked verbs, and the agent holds nothing - ward stops describing the boundary and becomes it. --- Researched and posted automatically by `ward agent claude reply` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent reply

ward agent claude reply ran a one-shot standard research pass on this question:

take this idea, then refine it to work inside of the ward-kdl framework. the entire setup has to run inside the lifetime and exec context of ward-kdl, with the ward-kdl gaurdfile being the thing that protects kubeconfig from direct access


ward-kdl as the exec-ancestor: the broker stops being a sidecar and becomes the launcher

The prior comment landed on the right mechanism (hold the credential in a principal the agent can't read) but reached for the wrong topology for this framework: a sidecar container or a standalone daemon. Your constraint is sharper and actually simpler. Don't run a second thing next to the agent. Make ward-kdl the process the agent is launched inside of, and let the guardfile declare the fork/drop/RPC topology that keeps the kubeconfig on ward-kdl's side of the exec.

That single move reconciles the earlier "ward is forever bypassable" verdict with "it all runs inside ward-kdl's lifetime and exec context." The earlier verdict was true under one assumption: ward runs in the agent's process, under the agent's uid, so any credential ward can read the agent can read. ward-kdl inverts the parent/child relationship. ward-kdl is the parent. The agent is its child. The credential is acquired by the parent before it drops privileges and execs the child, so it lives in a process and namespace the child provably cannot reach. This is the classic privilege-separated supervisor pattern (acquire-then-drop-then-fork, the way a setuid helper or ssh-agent's holder works), expressed declaratively as a guardfile instead of hand-rolled C.

The exec context is the boundary, and the guardfile is its spec

The whole session is ward-kdl run agent-k8s-readonly.kdl -- <agent-cmd>. Walk the lifetime:

  1. ward-kdl starts as the trusted principal. It reads the guardfile, mints the agent-viewer token (or resolves it from kai-server), and holds it in its own process memory only. Nothing on disk for a child to find.
  2. ward-kdl sets up one control fd (a socketpair) that will cross the exec, then forks.
  3. The child is stripped and exec'd into the agent. Dropped to a non-privileged uid, mount/IPC namespaces unshared so it cannot see ward-kdl's tmpfs or memory, environment scrubbed of any kube creds, and no inherited fds except the control socket. The agent now has no kubeconfig, no token, no path to mint one (minting needs the bootstrap creds that never left the parent), and no network kubeconfig to curl with.
  4. The agent's only cluster path is the control fd. Its in-agent ward k8s get ... is a thin client that speaks over that fd to the supervising ward-kdl. It is not a fresh ward process that re-reads the guardfile and re-mints (that child would run under the agent uid with the stripped view and could never mint anyway). The parent holds credential and policy. The child holds a socket.
  5. When ward-kdl exits, the session ends. Control socket closes, in-memory token is gone, child is reaped. The credential's lifetime is exactly the supervisor's lifetime. That is what "inside the lifetime of ward-kdl" buys: no lingering token, no detachable session.

The critical correction to the sidecar framing: the boundary is the exec ancestry, not a uid living in the same container or a daemon on a port. Anything in the agent's process tree is equally untrusted (it can pass the control fd to a sub-tool, and that's fine). The fence is the supervisor on the far side of the socket, and the kernel's process/namespace separation is what the agent cannot smuggle past.

What the guardfile has to carry (illustrative KDL)

The guardfile is now load-bearing in a way ward.yaml never was, so it has to declare three things, not one: the credential source, the exec-strip topology, and the semantic verb surface. Shape (syntax illustrative, I'm inferring ward-kdl's exact grammar):

session "agent-k8s" {
    // held by ward-kdl, acquired before the privilege drop, never written
    credential "kube" {
        mint "k3s-token" sa="agent-viewer" duration="30m" refresh=true
        endpoint ssm="/coilysiren/kai-server/tailnet-fqdn"
        in-memory true
    }

    // the child is exec'd into here, on the far side of the credential
    exec {
        drop-privileges uid="agent"
        unshare "mount" "ipc"
        scrub-env "KUBECONFIG" "AWS_*"
        inherit-fds "control"      // the RPC socket is the ONLY thing that crosses
    }

    // the agent's entire cluster vocabulary, brokered over the control fd
    guard "k8s" via="control" {
        verb "get"      { allow-args "-n" "-o=name|json|yaml|wide" }
        verb "describe" { allow-args "-n" }
        verb "logs"     { allow-args "-n" "--tail" "-f" }
        verb "top"
        deny-args "--as" "--as-group" "--token" "--server" "--kubeconfig" "-f" "--raw"
        deny-verb "exec" "attach" "port-forward" "proxy" "cp" \
                  "apply" "delete" "patch" "edit" "replace" "scale"
    }
}

The one rule that makes or breaks it (carried straight from the prior comment, now living in the guardfile): the guard "k8s" block exposes semantic, policy-checked operations, and ward-kdl constructs the kubectl argv itself. If the guard proxies raw argv it has just relocated the arg-smuggling surface (-f -, --as, exec, --raw, templated shell-outs) to the far side of the socket and gained nothing. The deny-args / deny-verb lines are the allowlist's teeth, and they live next to the credential they protect, in the file ward-kdl reads at the trusted moment before the drop.

Why this is strictly better than both prior endpoints

  • vs. the build-tagged binary: build-tagging removed mutation code from the agent's binary, but the agent still held the token, so an injected agent ignored ward and curl'd the API. Here the agent holds no token. Its only capability is "ask the supervisor," bounded by the guard block. The credential, not just the code path, is gone from the agent's reach.
  • vs. the sidecar daemon: no second container to schedule, no listening port to ACL, no SO_PEERCRED dance to prove who dialed. The control fd exists only because ward-kdl created it and passed it across its own exec, so reachability is established by the process tree, not by network or filesystem ACLs. One binary, one guardfile, one lifetime.

The guardfile is now your trusted computing base, so the prior trust-proving plan retargets onto it

The forcing-function plan from the earlier comment still holds, but the red-team suite now points at the guardfile semantics instead of at a daemon:

  • The sharpest test gets sharper: "agent attempts to locate or read any token/kubeconfig anywhere in its view (env, fds, mounts, /proc/<parent>) — expected: nothing exists, and /proc/<parent>/mem is unreadable across the privilege drop." That assertion is the proof that ward-kdl became the wall, and the in-process model could never pass it.
  • Arg-smuggling tests fire over the control fd against the deny-args/deny-verb lines.
  • Direct-token-bypass test changes meaning: previously "allowed but harmless (RBAC floor)." Here it should be "impossible, no credential present" — and if the agent somehow finds one, that's a P0 finding about the exec strip, not an expected-harmless line.
  • RBAC stays the floor anyway: the agent-viewer SA is still view-only, no Secrets, no exec. A guardfile bug that lets a bad verb through still hits a token that can't mutate. The guard raises the boundary, it doesn't excuse a loose token.
  • Audit witness unchanged: k3s API audit log, observed server-side, confirms the view-only invariant through a channel neither ward-kdl nor the agent controls.

The unifying payoff carries over intact

Same architecture, different guard policy:

  • Read-only k3s now = the guardfile above. Total agent compromise yields reads, minus Secrets, on a 30m token the agent never touched.
  • The #342 deployer write path later = the same session block with a credential minting the deployer token and a guard "k8s" whose verbs are the scoped rollout (optionally gated on an o2r request-envelope a human approves before ward-kdl executes). The agent still holds nothing. Only the guardfile string changes.

So the read-only exercise is the place to build the ward-kdl session/exec/guard machinery itself, on a credential that can't hurt anyone, so that when the deploy path turns on, the trusted mechanism is already proven and only the policy widens.

Residual risks specific to this model

  • The privilege drop is the real boundary, not the guard syntax. If the unshare/scrub/fd-strip is incomplete (a leaked KUBECONFIG, an inherited fd, a readable /proc/<parent>/mem because CAP_SYS_PTRACE wasn't dropped), the guardfile is decorative. Test the strip adversarially before trusting any guard block above it.
  • The guardfile is YAML's replacement as the injectable surface. It must be owned by ward-kdl at the trusted moment and not writable by the agent uid. An agent that can edit its own guardfile before the drop owns the session. Render it, lock its perms, and treat any in-session edit attempt as a finding (same discipline as the ward-managed lockdown files).
  • The control-fd protocol is the new parser to fuzz. Treat every request from the child as hostile input (confused-deputy: never trust the agent's framing of what it's allowed to do — the guard re-derives that from the policy, not from the request).
  • Mint authority must live only in the parent. The whole model collapses if the agent's environment retains any path to k3s kubectl create token or the kai-server creds behind it. Scrub aggressively and assert it in the suite.

Concrete first shape for this repo

  • deploy/agent-viewer-rbac.yml: ServiceAccount agent-viewer + ClusterRoleBinding to the built-in view ClusterRole (no Secrets, no exec). Mirrors deploy/forgejo-runner-deploy.yml's shape, unchanged from prior research.
  • guardfiles/agent-k8s-readonly.kdl: the session block above. The credential mint, the exec strip, and the semantic k8s guard in one file.
  • ward-kdl's in-agent ward k8s ... stub: a control-fd client, no kubeconfig, no mint verb on the agent side.
  • docs/ward-kdl-broker.md (sibling of docs/cd-runner.md): the acquire-drop-fork model, the "exec ancestry is the boundary" principle, the "semantic verbs only, the guard owns the argv" rule, and the read-only-now / deploy-later policy split.
  • The red-team suite as a checked-in target that runs on every ward-kdl release, leading with the "no credential exists in the child's view" assertion.

The one-line version: don't put a broker beside the agent and don't trust an in-process gate in front of the agent's own credential. Make ward-kdl the process the agent is born inside of, acquire the kubeconfig before the privilege drop, and let the guardfile declare the strip and the semantic verb surface. The agent inherits a socket, not a secret, and ward-kdl stops describing the boundary and becomes it.


Researched and posted automatically by ward agent claude reply (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent reply `ward agent claude reply` ran a one-shot **standard** research pass on this question: > take this idea, then refine it to work inside of the ward-kdl framework. the entire setup has to run inside the lifetime and exec context of ward-kdl, with the ward-kdl gaurdfile being the thing that protects kubeconfig from direct access --- ## ward-kdl as the exec-ancestor: the broker stops being a sidecar and becomes the launcher The prior comment landed on the right mechanism (hold the credential in a principal the agent can't read) but reached for the wrong topology for this framework: a sidecar container or a standalone daemon. Your constraint is sharper and actually simpler. Don't run a second thing next to the agent. Make **ward-kdl the process the agent is launched inside of**, and let the guardfile declare the fork/drop/RPC topology that keeps the kubeconfig on ward-kdl's side of the exec. That single move reconciles the earlier "ward is forever bypassable" verdict with "it all runs inside ward-kdl's lifetime and exec context." The earlier verdict was true under one assumption: ward runs **in** the agent's process, under the agent's uid, so any credential ward can read the agent can read. ward-kdl inverts the parent/child relationship. ward-kdl is the parent. The agent is its child. The credential is acquired by the parent **before** it drops privileges and execs the child, so it lives in a process and namespace the child provably cannot reach. This is the classic privilege-separated supervisor pattern (acquire-then-drop-then-fork, the way a setuid helper or `ssh-agent`'s holder works), expressed declaratively as a guardfile instead of hand-rolled C. ## The exec context is the boundary, and the guardfile is its spec The whole session is `ward-kdl run agent-k8s-readonly.kdl -- <agent-cmd>`. Walk the lifetime: 1. **ward-kdl starts as the trusted principal.** It reads the guardfile, mints the `agent-viewer` token (or resolves it from kai-server), and holds it **in its own process memory only**. Nothing on disk for a child to find. 2. **ward-kdl sets up one control fd** (a `socketpair`) that will cross the exec, then forks. 3. **The child is stripped and exec'd into the agent.** Dropped to a non-privileged uid, mount/IPC namespaces unshared so it cannot see ward-kdl's tmpfs or memory, environment scrubbed of any kube creds, and **no inherited fds except the control socket**. The agent now has no kubeconfig, no token, no path to mint one (minting needs the bootstrap creds that never left the parent), and no network kubeconfig to `curl` with. 4. **The agent's only cluster path is the control fd.** Its in-agent `ward k8s get ...` is a thin client that speaks over that fd to the supervising ward-kdl. It is **not** a fresh `ward` process that re-reads the guardfile and re-mints (that child would run under the agent uid with the stripped view and could never mint anyway). The parent holds credential and policy. The child holds a socket. 5. **When ward-kdl exits, the session ends.** Control socket closes, in-memory token is gone, child is reaped. The credential's lifetime is exactly the supervisor's lifetime. That is what "inside the lifetime of ward-kdl" buys: no lingering token, no detachable session. The critical correction to the sidecar framing: the boundary is **the exec ancestry**, not a uid living in the same container or a daemon on a port. Anything in the agent's process tree is equally untrusted (it can pass the control fd to a sub-tool, and that's fine). The fence is the supervisor on the far side of the socket, and the kernel's process/namespace separation is what the agent cannot smuggle past. ## What the guardfile has to carry (illustrative KDL) The guardfile is now load-bearing in a way `ward.yaml` never was, so it has to declare three things, not one: the credential source, the exec-strip topology, and the **semantic** verb surface. Shape (syntax illustrative, I'm inferring ward-kdl's exact grammar): ```kdl session "agent-k8s" { // held by ward-kdl, acquired before the privilege drop, never written credential "kube" { mint "k3s-token" sa="agent-viewer" duration="30m" refresh=true endpoint ssm="/coilysiren/kai-server/tailnet-fqdn" in-memory true } // the child is exec'd into here, on the far side of the credential exec { drop-privileges uid="agent" unshare "mount" "ipc" scrub-env "KUBECONFIG" "AWS_*" inherit-fds "control" // the RPC socket is the ONLY thing that crosses } // the agent's entire cluster vocabulary, brokered over the control fd guard "k8s" via="control" { verb "get" { allow-args "-n" "-o=name|json|yaml|wide" } verb "describe" { allow-args "-n" } verb "logs" { allow-args "-n" "--tail" "-f" } verb "top" deny-args "--as" "--as-group" "--token" "--server" "--kubeconfig" "-f" "--raw" deny-verb "exec" "attach" "port-forward" "proxy" "cp" \ "apply" "delete" "patch" "edit" "replace" "scale" } } ``` The one rule that makes or breaks it (carried straight from the prior comment, now living in the guardfile): the `guard "k8s"` block exposes **semantic, policy-checked operations**, and ward-kdl constructs the kubectl argv itself. If the guard proxies raw argv it has just relocated the arg-smuggling surface (`-f -`, `--as`, `exec`, `--raw`, templated shell-outs) to the far side of the socket and gained nothing. The `deny-args` / `deny-verb` lines are the allowlist's teeth, and they live next to the credential they protect, in the file ward-kdl reads at the trusted moment before the drop. ## Why this is strictly better than both prior endpoints - **vs. the build-tagged binary:** build-tagging removed mutation *code* from the agent's binary, but the agent still **held the token**, so an injected agent ignored ward and `curl`'d the API. Here the agent holds no token. Its only capability is "ask the supervisor," bounded by the guard block. The credential, not just the code path, is gone from the agent's reach. - **vs. the sidecar daemon:** no second container to schedule, no listening port to ACL, no SO_PEERCRED dance to prove who dialed. The control fd exists **only** because ward-kdl created it and passed it across its own exec, so reachability is established by the process tree, not by network or filesystem ACLs. One binary, one guardfile, one lifetime. ## The guardfile is now your trusted computing base, so the prior trust-proving plan retargets onto it The forcing-function plan from the earlier comment still holds, but the red-team suite now points at the guardfile semantics instead of at a daemon: - **The sharpest test gets sharper:** "agent attempts to locate or read any token/kubeconfig anywhere in its view (env, fds, mounts, `/proc/<parent>`) — expected: nothing exists, and `/proc/<parent>/mem` is unreadable across the privilege drop." That assertion is the proof that ward-kdl became the wall, and the in-process model could never pass it. - **Arg-smuggling tests fire over the control fd** against the `deny-args`/`deny-verb` lines. - **Direct-token-bypass test changes meaning:** previously "allowed but harmless (RBAC floor)." Here it should be **"impossible, no credential present"** — and if the agent somehow finds one, that's a P0 finding about the exec strip, not an expected-harmless line. - **RBAC stays the floor anyway:** the `agent-viewer` SA is still `view`-only, no Secrets, no exec. A guardfile bug that lets a bad verb through still hits a token that can't mutate. The guard raises the boundary, it doesn't excuse a loose token. - **Audit witness unchanged:** k3s API audit log, observed server-side, confirms the `view`-only invariant through a channel neither ward-kdl nor the agent controls. ## The unifying payoff carries over intact Same architecture, different guard policy: - **Read-only k3s now** = the guardfile above. Total agent compromise yields reads, minus Secrets, on a 30m token the agent never touched. - **The #342 `deployer` write path later** = the same `session` block with a `credential` minting the `deployer` token and a `guard "k8s"` whose verbs are the scoped rollout (optionally gated on an o2r request-envelope a human approves before ward-kdl executes). The agent still holds nothing. Only the guardfile string changes. So the read-only exercise is the place to **build the ward-kdl session/exec/guard machinery itself**, on a credential that can't hurt anyone, so that when the deploy path turns on, the trusted mechanism is already proven and only the policy widens. ## Residual risks specific to this model - **The privilege drop is the real boundary, not the guard syntax.** If the unshare/scrub/fd-strip is incomplete (a leaked `KUBECONFIG`, an inherited fd, a readable `/proc/<parent>/mem` because `CAP_SYS_PTRACE` wasn't dropped), the guardfile is decorative. Test the strip adversarially before trusting any guard block above it. - **The guardfile is YAML's replacement as the injectable surface.** It must be owned by ward-kdl at the trusted moment and **not** writable by the agent uid. An agent that can edit its own guardfile before the drop owns the session. Render it, lock its perms, and treat any in-session edit attempt as a finding (same discipline as the ward-managed lockdown files). - **The control-fd protocol is the new parser to fuzz.** Treat every request from the child as hostile input (confused-deputy: never trust the agent's framing of what it's allowed to do — the guard re-derives that from the policy, not from the request). - **Mint authority must live only in the parent.** The whole model collapses if the agent's environment retains any path to `k3s kubectl create token` or the kai-server creds behind it. Scrub aggressively and assert it in the suite. ## Concrete first shape for this repo - `deploy/agent-viewer-rbac.yml`: `ServiceAccount agent-viewer` + `ClusterRoleBinding` to the built-in `view` ClusterRole (no Secrets, no exec). Mirrors `deploy/forgejo-runner-deploy.yml`'s shape, unchanged from prior research. - `guardfiles/agent-k8s-readonly.kdl`: the `session` block above. The credential mint, the exec strip, and the semantic `k8s` guard in one file. - ward-kdl's in-agent `ward k8s ...` stub: a control-fd client, no kubeconfig, no mint verb on the agent side. - `docs/ward-kdl-broker.md` (sibling of `docs/cd-runner.md`): the acquire-drop-fork model, the "exec ancestry is the boundary" principle, the "semantic verbs only, the guard owns the argv" rule, and the read-only-now / deploy-later policy split. - The red-team suite as a checked-in target that runs on every ward-kdl release, leading with the "no credential exists in the child's view" assertion. The one-line version: don't put a broker beside the agent and don't trust an in-process gate in front of the agent's own credential. Make ward-kdl the process the agent is born inside of, acquire the kubeconfig before the privilege drop, and let the guardfile declare the strip and the semantic verb surface. The agent inherits a socket, not a secret, and ward-kdl stops describing the boundary and becomes it. --- Researched and posted automatically by `ward agent claude reply` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent reply

ward agent claude reply ran a one-shot standard research pass on this question:

we are closer, but not quite there. the session agent-k8s style gaurdfile is redefining a lot of the kdl spec that already lives inside the core kubectl gaurdfile here https://forgejo.coilysiren.me/coilyco-flight-deck/ward/src/branch/main/cmd/ward-kdl/ward-kdl.kubectl.guardfile.kdl - it shouldnt need to do that. ward as the exec parent and ward as the internal devtool should be able to exist at the same time, via the same binary, simply operating in two modes


Right — the session block was re-listing policy that already has a home. Compose the wrap, don't redefine it.

Looking at the actual ward-kdl.kubectl.guardfile.kdl, the duplication is exactly what you're pointing at. That file already is the verb surface:

wrap ward-kdl ops kubectl {
    exec kubectl
    can run get { describe "..." }
    can run logs { ... }
    can run apply { ... }      // the friendly deploy surface
    ...
    never run delete
    never run exec
    never run patch
}

That block is deny-by-default, binary-fixed-at-parse, arg-disciplined, and audit-logged. My session { guard "k8s" { verb "get"; deny-verb "exec" ... } } invented a parallel grammar (credential/exec/guard/verb/allow-args) that re-encodes the same can run / never run truth in a second place. That's the drift you'd pay for forever: every time the wrap learns a verb, the session guardfile would have to learn it too, and the two would silently disagree. Wrong. The verb surface must live in exactly one file, and it already does.

The two modes are already latent in the binary — name them, don't fork them

The same ward-kdl should run in two modes that share everything except where the privileged exec lands:

  • ops / devtool mode (inner). ward-kdl ops kubectl get pods -n atlas. Parses argv, checks it against the wrap allowlist, then execs the fixed local kubectl against the ambient kubeconfig. This is today's behavior on your host, where you're the trusted operator.
  • supervisor / exec-parent mode (outer). ward-kdl run agent-k8s.guardfile.kdl -- <agent-cmd>. ward becomes the exec ancestor: mints the scoped token before the privilege drop, holds it in its own memory, unshares/scrubs/fd-strips, then execs the agent as a child whose only cluster path is ward-kdl ops kubectl.

These aren't two binaries or two grammars. They're one binary, and the mode is determined by how the process was started, not by anything the agent can set.

The mechanism that unifies them: exec is a backend, selected by exec ancestry

exec kubectl in the wrap is a capability, not a hardcoded local fork. Resolve it through whichever exec backend is live:

  • direct backend (default, devtool mode): allowlisted argv → fork/exec the fixed local kubectl against the ambient kubeconfig.
  • relay backend (supervised mode): the same allowlisted argv is shipped over the inherited control fd to the parent ward, which holds the credential and runs the direct backend on the child's behalf.

The child still parses and allowlist-checks locally — same code, same wrap file, same fast-fail UX. The difference is only that the privileged exec happens in the parent. The agent can't flip back to the direct backend because the backend is chosen by exec ancestry (did I inherit a control fd from a ward parent?), not by an arg or env the child controls. The wrap's "binary fixed at parse, caller can never substitute it" property holds identically on both backends — the parent fixes it in relay mode.

That is the precise reading of "same binary, two modes": the mode is which exec backend the wrap's exec resolves to, and that's set by how ward-kdl was launched.

What the supervisor guardfile shrinks to

It carries only the three things the wrap doesn't already own — credential, exec strip, and which existing wrap to mount — in the same KDL idiom, and it references the wrap instead of restating it:

// agent-k8s-readonly.guardfile.kdl - supervisor mode.
// Owns credential + exec strip ONLY. Verb policy is the existing wrap.
supervise ward-kdl agent k8s {
    credential kube {
        mint k3s-token sa=agent-viewer duration=30m
        endpoint ssm="/coilysiren/kai-server/tailnet-fqdn"
        in-memory true        // never written where the child can read it
    }
    exec {
        drop-to agent
        unshare mount ipc
        scrub-env KUBECONFIG "AWS_*"
        inherit control-fd    // the ONLY thing that crosses the exec
    }
    // the single cluster path the child gets: the existing wrap, run on the
    // relay backend against the held token. Narrow to a read subset of what
    // the wrap already defines - this can only ever SELECT, never add a verb.
    expose ward-kdl ops kubectl {
        only get describe logs events top explain \
             api-resources api-versions cluster-info version
    }
}

get, logs, never run delete are defined once, in the wrap. The supervisor only-narrows a subset and binds a credential. The deploy verbs (apply/scale/rollout) that the base wrap exposes simply aren't mounted in this read-only session — and even if only were wrong, the agent-viewer token is view-RBAC, so apply hits a 403 floor.

Read-only now / deploy later is the same supervisor block, two knobs

  • Read-only k3s: mint the agent-viewer (view) token, only the read subset above.
  • The deploy path (infrastructure#342's deployer): same supervise block, mint deployer, only get describe logs apply scale rollout. The base wrap is still the single source of truth for what apply/scale/rollout mean and how their args are disciplined. Only the token and the only line change. The agent never holds either token — the relay backend keeps both in the parent — so the credential-broker property the prior comments converged on survives intact, now with zero policy duplication.

Why this is the right cut

  • One source of verb truth. Adding a verb to ward-kdl ops kubectl automatically updates every supervised session that exposes it. No second list to drift.
  • Devtool and supervisor coexist by construction. Same wrap file, same allowlist code path, exercised on your host (direct) and under an agent (relay). The supervised path can't diverge from the path you dogfood daily, which is the cheapest possible way to keep the agent-facing surface honest.
  • The exec strip stays the real boundary. The credential property doesn't come from the guardfile syntax — it comes from the privilege drop and the relay backend keeping the token in the parent. The guardfile just declares it.

Residual things to get right

  • only must be subset-checked against the wrap at parse, and can never widen it. It selects from can run, it never introduces a verb or re-grants a never run. If only names something the wrap doesn't expose, that's a parse error, not a silent grant.
  • Backend selection is ancestry-determined, not arg-selectable. A child must not be able to request the direct backend (e.g. via a flag, env, or a forged control fd). The presence of a real inherited control fd from a ward parent is the signal; absent it, there's no credential to reach anyway.
  • The relay protocol is the new parser to fuzz. It carries already-allowlisted argv, but treat every child request as hostile input — the parent re-derives authorization from the wrap + only, never from the child's framing.
  • Mint authority lives only in the parent. Scrub aggressively enough that the child retains no path to k3s kubectl create token or the kai-server creds behind it, and assert "no credential exists in the child's view" in the red-team suite — that assertion is still the proof the boundary holds.

Concrete first shape

  • deploy/agent-viewer-rbac.yml: ServiceAccount agent-viewer + ClusterRoleBinding to built-in view (unchanged).
  • Keep ward-kdl.kubectl.guardfile.kdl as-is — it's the verb surface for both modes.
  • Add the supervise block + expose … only composition + the relay exec backend to ward-kdl (the binary work, not a new guardfile grammar).
  • guardfiles/agent-k8s-readonly.guardfile.kdl: the small supervisor block above — credential, exec strip, expose ward-kdl ops kubectl { only … }.
  • docs/ward-kdl.kubectl.guardfile.md sibling docs/ward-kdl-supervisor.md: the two-mode model, "the wrap is the single verb truth, the supervisor composes it," and the direct-vs-relay exec backend.

The one-line version: the supervisor guardfile shouldn't know what get or delete mean — that's the wrap's job in both modes. ward runs the same wrap either as a host devtool (direct exec) or as the exec parent of an agent (relay exec against a held token), and the supervisor file only adds the credential, the strip, and an only-narrowed expose of the wrap that already exists.


Researched and posted automatically by ward agent claude reply (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent reply `ward agent claude reply` ran a one-shot **standard** research pass on this question: > we are closer, but not quite there. the session agent-k8s style gaurdfile is redefining a lot of the kdl spec that already lives inside the core kubectl gaurdfile here https://forgejo.coilysiren.me/coilyco-flight-deck/ward/src/branch/main/cmd/ward-kdl/ward-kdl.kubectl.guardfile.kdl - it shouldnt need to do that. ward as the exec parent and ward as the internal devtool should be able to exist at the same time, via the same binary, simply operating in two modes --- ## Right — the session block was re-listing policy that already has a home. Compose the wrap, don't redefine it. Looking at the actual `ward-kdl.kubectl.guardfile.kdl`, the duplication is exactly what you're pointing at. That file already is the verb surface: ```kdl wrap ward-kdl ops kubectl { exec kubectl can run get { describe "..." } can run logs { ... } can run apply { ... } // the friendly deploy surface ... never run delete never run exec never run patch } ``` That block is deny-by-default, binary-fixed-at-parse, arg-disciplined, and audit-logged. My `session { guard "k8s" { verb "get"; deny-verb "exec" ... } }` invented a parallel grammar (`credential`/`exec`/`guard`/`verb`/`allow-args`) that re-encodes the same `can run` / `never run` truth in a second place. That's the drift you'd pay for forever: every time the wrap learns a verb, the session guardfile would have to learn it too, and the two would silently disagree. Wrong. The verb surface must live in exactly one file, and it already does. ## The two modes are already latent in the binary — name them, don't fork them The same `ward-kdl` should run in two modes that share everything except where the privileged `exec` lands: - **ops / devtool mode (inner).** `ward-kdl ops kubectl get pods -n atlas`. Parses argv, checks it against the `wrap` allowlist, then execs the fixed local `kubectl` against the ambient kubeconfig. This is today's behavior on your host, where you're the trusted operator. - **supervisor / exec-parent mode (outer).** `ward-kdl run agent-k8s.guardfile.kdl -- <agent-cmd>`. ward becomes the exec ancestor: mints the scoped token before the privilege drop, holds it in its own memory, unshares/scrubs/fd-strips, then execs the agent as a child whose **only** cluster path is `ward-kdl ops kubectl`. These aren't two binaries or two grammars. They're one binary, and the mode is determined by **how the process was started**, not by anything the agent can set. ## The mechanism that unifies them: `exec` is a backend, selected by exec ancestry `exec kubectl` in the wrap is a capability, not a hardcoded local fork. Resolve it through whichever **exec backend** is live: - **direct backend** (default, devtool mode): allowlisted argv → fork/exec the fixed local `kubectl` against the ambient kubeconfig. - **relay backend** (supervised mode): the *same* allowlisted argv is shipped over the inherited control fd to the parent `ward`, which holds the credential and runs the direct backend on the child's behalf. The child still parses and allowlist-checks locally — same code, same wrap file, same fast-fail UX. The difference is only that the privileged exec happens in the parent. The agent can't flip back to the direct backend because the backend is chosen by exec ancestry (did I inherit a control fd from a ward parent?), not by an arg or env the child controls. The wrap's "binary fixed at parse, caller can never substitute it" property holds identically on both backends — the parent fixes it in relay mode. That is the precise reading of "same binary, two modes": the mode is which exec backend the `wrap`'s `exec` resolves to, and that's set by how `ward-kdl` was launched. ## What the supervisor guardfile shrinks to It carries only the three things the wrap *doesn't* already own — credential, exec strip, and which existing wrap to mount — in the same KDL idiom, and it **references** the wrap instead of restating it: ```kdl // agent-k8s-readonly.guardfile.kdl - supervisor mode. // Owns credential + exec strip ONLY. Verb policy is the existing wrap. supervise ward-kdl agent k8s { credential kube { mint k3s-token sa=agent-viewer duration=30m endpoint ssm="/coilysiren/kai-server/tailnet-fqdn" in-memory true // never written where the child can read it } exec { drop-to agent unshare mount ipc scrub-env KUBECONFIG "AWS_*" inherit control-fd // the ONLY thing that crosses the exec } // the single cluster path the child gets: the existing wrap, run on the // relay backend against the held token. Narrow to a read subset of what // the wrap already defines - this can only ever SELECT, never add a verb. expose ward-kdl ops kubectl { only get describe logs events top explain \ api-resources api-versions cluster-info version } } ``` `get`, `logs`, `never run delete` are defined once, in the wrap. The supervisor `only`-narrows a subset and binds a credential. The deploy verbs (`apply`/`scale`/`rollout`) that the base wrap exposes simply aren't mounted in this read-only session — and even if `only` were wrong, the `agent-viewer` token is `view`-RBAC, so `apply` hits a 403 floor. ## Read-only now / deploy later is the same supervisor block, two knobs - **Read-only k3s:** `mint` the `agent-viewer` (view) token, `only` the read subset above. - **The deploy path** ([infrastructure#342](https://forgejo.coilysiren.me/coilyco-flight-deck/infrastructure/issues/342)'s `deployer`): same `supervise` block, `mint deployer`, `only get describe logs apply scale rollout`. The base wrap is *still the single source of truth* for what `apply`/`scale`/`rollout` mean and how their args are disciplined. Only the token and the `only` line change. The agent never holds either token — the relay backend keeps both in the parent — so the credential-broker property the prior comments converged on survives intact, now with zero policy duplication. ## Why this is the right cut - **One source of verb truth.** Adding a verb to `ward-kdl ops kubectl` automatically updates every supervised session that exposes it. No second list to drift. - **Devtool and supervisor coexist by construction.** Same wrap file, same allowlist code path, exercised on your host (direct) and under an agent (relay). The supervised path can't diverge from the path you dogfood daily, which is the cheapest possible way to keep the agent-facing surface honest. - **The exec strip stays the real boundary.** The credential property doesn't come from the guardfile syntax — it comes from the privilege drop and the relay backend keeping the token in the parent. The guardfile just declares it. ## Residual things to get right - **`only` must be subset-checked against the wrap at parse, and can never widen it.** It selects from `can run`, it never introduces a verb or re-grants a `never run`. If `only` names something the wrap doesn't expose, that's a parse error, not a silent grant. - **Backend selection is ancestry-determined, not arg-selectable.** A child must not be able to request the direct backend (e.g. via a flag, env, or a forged control fd). The presence of a real inherited control fd from a ward parent is the signal; absent it, there's no credential to reach anyway. - **The relay protocol is the new parser to fuzz.** It carries already-allowlisted argv, but treat every child request as hostile input — the parent re-derives authorization from the wrap + `only`, never from the child's framing. - **Mint authority lives only in the parent.** Scrub aggressively enough that the child retains no path to `k3s kubectl create token` or the kai-server creds behind it, and assert "no credential exists in the child's view" in the red-team suite — that assertion is still the proof the boundary holds. ## Concrete first shape - `deploy/agent-viewer-rbac.yml`: `ServiceAccount agent-viewer` + `ClusterRoleBinding` to built-in `view` (unchanged). - **Keep `ward-kdl.kubectl.guardfile.kdl` as-is** — it's the verb surface for both modes. - Add the `supervise` block + `expose … only` composition + the relay exec backend to `ward-kdl` (the binary work, not a new guardfile grammar). - `guardfiles/agent-k8s-readonly.guardfile.kdl`: the small supervisor block above — credential, exec strip, `expose ward-kdl ops kubectl { only … }`. - `docs/ward-kdl.kubectl.guardfile.md` sibling `docs/ward-kdl-supervisor.md`: the two-mode model, "the wrap is the single verb truth, the supervisor composes it," and the direct-vs-relay exec backend. The one-line version: the supervisor guardfile shouldn't know what `get` or `delete` mean — that's the wrap's job in both modes. ward runs the same wrap either as a host devtool (direct exec) or as the exec parent of an agent (relay exec against a held token), and the supervisor file only adds the credential, the strip, and an `only`-narrowed `expose` of the wrap that already exists. --- Researched and posted automatically by `ward agent claude reply` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent reply

ward agent claude reply ran a one-shot standard research pass on this question:

lets keep going. you added a bunch of keywords into the supervise style guardfile that dont overlap with the devtool gaurdfile. this is keep from a seperation of concerns point of view, but from a human UX point of view you just duplicated a kubectl KDL spec, and the 2 specs share no overlapping keys. so again: lets compress this into a form that operates inside of ward-kdl, and inside of a single gaurdfile


One file, one wrap — the supervisor facts hang off keys that already exist

You're right, and the tell is exactly what you named: supervise, credential, exec (re-defined), guard, verb, expose, only were a second KDL dialect sitting next to wrap / exec / can run / never run and sharing zero keys with it. Two specs a human has to learn, that drift the moment one grows a verb. The compression is to notice that I only ever introduced two genuinely new facts, and both are properties of things the kubectl guardfile already declares — so they attach to existing nodes instead of forming a parallel tree.

The two new facts:

  1. Where the credential comes from (mint + endpoint) — that's a property of how exec runs.
  2. How the child is born (drop / unshare / scrub) — also a property of how exec runs.

Everything else I invented (guard, verb, expose … only) was re-encoding the verb truth that can run / never run already hold. That part shouldn't exist at all. The read/write split is not a new vocabulary — it's one tag on the verbs already listed.

The single guardfile

Take the real ward-kdl.kubectl.guardfile.kdl as-is and make three small, in-vocabulary additions. No second file, no second grammar:

wrap ward-kdl ops kubectl {
    // `exec` gains optional children that fire ONLY when ward is the
    // exec-parent (a supervised launch). Run as a host devtool and there is
    // no parent, no mint, no drop - ambient kubeconfig, these are inert.
    exec kubectl {
        as agent-viewer {
            mint "k3s-token" sa=agent-viewer duration=30m
            endpoint ssm="/coilysiren/kai-server/tailnet-fqdn"
            grant read                 // unlocks every verb tagged tier=read
        }
        as deployer {
            mint "k3s-token" sa=deployer duration=30m
            endpoint ssm="/coilysiren/kai-server/tailnet-fqdn"
            grant read write           // read subset + the deploy verbs
        }
        fence {
            drop-to agent
            unshare mount ipc
            scrub-env "KUBECONFIG" "AWS_*"
        }
    }

    // verbs listed ONCE, exactly as today. the only new token is `tier=`.
    can run get      tier=read  { describe "list/show resources; type + name" }
    can run describe tier=read  { describe "detailed resource state" }
    can run logs     tier=read  { describe "pod/container logs; -f, --previous" }
    can run top      tier=read  { describe "resource usage; pod|node" }
    can run apply    tier=write { describe "apply manifests; -f / -k" }
    can run scale    tier=write { describe "set replica count" }
    can run "rollout status" tier=write { describe "watch a rollout" }
    never run delete
    never run exec
    never run patch
    never run drain
    never run edit
}

That's the whole thing. wrap, exec, can run, never run are the same keys the devtool already uses. The additions are: a tier= property on verbs that already exist, and as / mint / endpoint / grant / fence as children of exec — not a top-level parallel spec.

Why this is genuinely one spec, not two wearing a shared bracket

  • The verb surface is declared once. Adding can run cordon tier=write to the wrap automatically reaches every write identity and no read identity. There is no second list to keep in sync, which was the entire complaint. tier is the only new word a human learns on the policy side, and it reads in English.

  • The new keys live under the key they modify. Credential and fence are facts about exec, so they nest under exec. A reader scanning the file sees one wrap, and "how does exec get its creds / how is the child fenced" answered right there, not in a disjoint block with its own vocabulary.

  • There is no "supervisor mode" in the file. This was the real over-engineering. Direct-devtool vs relay-supervised is decided by exec ancestry at launch, not declared in KDL. Same file, both ways:

    • ward-kdl ops kubectl get pods -n atlas → no ward parent → direct exec against the ambient kubeconfig. as / mint / fence never fire. This is today's behavior, byte-for-byte.
    • ward-kdl run --as agent-viewer -- claude … → ward is the exec-parent → it reads the same as agent-viewer block, mints and holds the token in its own memory, applies fence, drops, and execs the child whose only cluster path is ward-kdl ops kubectl relayed back to the parent.

    The child cannot pick --as or the direct backend — both are fixed by the parent before the privilege drop, established by the process tree, not by anything the child can set. So the read-only-ness comes from grant read binding the mint to tier=read, backstopped by the agent-viewer RBAC floor.

Read-only now, deploy later — same file, one launch flag

Nothing forks. The deploy path (the deployer identity from #342) is already in the file above as the second as block. The agent never holds either token (the parent relays), so the credential-broker property the thread converged on survives with zero policy duplication. The difference between observe-the-cluster and roll-out-atlas is one word at launch: --as agent-viewer vs --as deployer.

Residuals to get right (unchanged in substance, smaller in surface)

  • tier on grant must be subset-only. grant selects from can run by tier and can never name a verb or re-grant a never run. If a future grant references a tier no verb carries, that's a parse warning, not a silent empty grant.
  • The fence is still the real boundary, not the syntax. drop-to / unshare / scrub-env being complete is what keeps the token out of the child. Test the strip adversarially before trusting any tier tag above it. The red-team suite's sharpest assertion stays: "no credential exists in the child's view".
  • The file is the injectable surface. It must be ward-owned and not writable by the agent uid, same discipline as the lockdown files. An agent that edits its own tier=/grant before the drop owns the session.
  • RBAC stays the floor. agent-viewer = built-in view (no Secrets, no exec). A tier bug still hits a 403.

Concrete shape

  • Keep ward-kdl.kubectl.guardfile.kdl as the single guardfile. Add tier= to its verbs and the as / fence children to its exec. No separate read-only guardfile, no supervise grammar.
  • Binary work in ward-kdl: teach exec to read as/mint/fence children, add the relay backend, and select direct-vs-relay by exec ancestry. This is code, not a new dialect.
  • deploy/agent-viewer-rbac.yml: ServiceAccount agent-viewer + ClusterRoleBinding to built-in view (unchanged).
  • docs/ward-kdl.kubectl.guardfile.md: document tier, the exec children, and "the same wrap runs direct as a devtool or relayed as an exec-parent — mode is ancestry, not a keyword."

One-line version: stop writing a supervisor spec. The supervisor only ever added "where's the credential" and "how's the child fenced," both of which are properties of exec — so nest them there, tag each existing can run with tier=read|write so the verb truth stays listed once, and let exec-ancestry (not a KDL block) decide whether ward runs the wrap directly for Kai or relays it for an agent.


Standard-depth research, grounded against the live ward-kdl.kubectl.guardfile.kdl. One-shot — verify before acting.


Researched and posted automatically by ward agent claude reply (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent reply `ward agent claude reply` ran a one-shot **standard** research pass on this question: > lets keep going. you added a bunch of keywords into the supervise style guardfile that dont overlap with the devtool gaurdfile. this is keep from a seperation of concerns point of view, but from a human UX point of view you just duplicated a kubectl KDL spec, and the 2 specs share no overlapping keys. so again: lets compress this into a form that operates inside of ward-kdl, and inside of a single gaurdfile --- ## One file, one `wrap` — the supervisor facts hang off keys that already exist You're right, and the tell is exactly what you named: `supervise`, `credential`, `exec` (re-defined), `guard`, `verb`, `expose`, `only` were a second KDL dialect sitting next to `wrap` / `exec` / `can run` / `never run` and sharing **zero** keys with it. Two specs a human has to learn, that drift the moment one grows a verb. The compression is to notice that I only ever introduced **two genuinely new facts**, and both are properties of things the kubectl guardfile *already declares* — so they attach to existing nodes instead of forming a parallel tree. The two new facts: 1. **Where the credential comes from** (mint + endpoint) — that's a property of *how `exec` runs*. 2. **How the child is born** (drop / unshare / scrub) — also a property of *how `exec` runs*. Everything else I invented (`guard`, `verb`, `expose … only`) was re-encoding the verb truth that `can run` / `never run` already hold. That part shouldn't exist at all. The read/write split is not a new vocabulary — it's **one tag on the verbs already listed**. ## The single guardfile Take the real `ward-kdl.kubectl.guardfile.kdl` as-is and make three small, in-vocabulary additions. No second file, no second grammar: ```kdl wrap ward-kdl ops kubectl { // `exec` gains optional children that fire ONLY when ward is the // exec-parent (a supervised launch). Run as a host devtool and there is // no parent, no mint, no drop - ambient kubeconfig, these are inert. exec kubectl { as agent-viewer { mint "k3s-token" sa=agent-viewer duration=30m endpoint ssm="/coilysiren/kai-server/tailnet-fqdn" grant read // unlocks every verb tagged tier=read } as deployer { mint "k3s-token" sa=deployer duration=30m endpoint ssm="/coilysiren/kai-server/tailnet-fqdn" grant read write // read subset + the deploy verbs } fence { drop-to agent unshare mount ipc scrub-env "KUBECONFIG" "AWS_*" } } // verbs listed ONCE, exactly as today. the only new token is `tier=`. can run get tier=read { describe "list/show resources; type + name" } can run describe tier=read { describe "detailed resource state" } can run logs tier=read { describe "pod/container logs; -f, --previous" } can run top tier=read { describe "resource usage; pod|node" } can run apply tier=write { describe "apply manifests; -f / -k" } can run scale tier=write { describe "set replica count" } can run "rollout status" tier=write { describe "watch a rollout" } never run delete never run exec never run patch never run drain never run edit } ``` That's the whole thing. `wrap`, `exec`, `can run`, `never run` are the same keys the devtool already uses. The additions are: a `tier=` property on verbs that already exist, and `as` / `mint` / `endpoint` / `grant` / `fence` as **children of `exec`** — not a top-level parallel spec. ## Why this is genuinely one spec, not two wearing a shared bracket - **The verb surface is declared once.** Adding `can run cordon tier=write` to the wrap automatically reaches every write identity and no read identity. There is no second list to keep in sync, which was the entire complaint. `tier` is the *only* new word a human learns on the policy side, and it reads in English. - **The new keys live under the key they modify.** Credential and fence are facts about `exec`, so they nest under `exec`. A reader scanning the file sees one `wrap`, and "how does exec get its creds / how is the child fenced" answered right there, not in a disjoint block with its own vocabulary. - **There is no "supervisor mode" in the file.** This was the real over-engineering. Direct-devtool vs relay-supervised is decided by **exec ancestry at launch**, not declared in KDL. Same file, both ways: - `ward-kdl ops kubectl get pods -n atlas` → no ward parent → direct exec against the ambient kubeconfig. `as` / `mint` / `fence` never fire. This is today's behavior, byte-for-byte. - `ward-kdl run --as agent-viewer -- claude …` → ward is the exec-parent → it reads the **same** `as agent-viewer` block, mints and holds the token in its own memory, applies `fence`, drops, and execs the child whose only cluster path is `ward-kdl ops kubectl` relayed back to the parent. The child cannot pick `--as` or the direct backend — both are fixed by the parent before the privilege drop, established by the process tree, not by anything the child can set. So the read-only-ness comes from `grant read` binding the mint to `tier=read`, backstopped by the `agent-viewer` RBAC floor. ## Read-only now, deploy later — same file, one launch flag Nothing forks. The deploy path (the `deployer` identity from https://forgejo.coilysiren.me/coilyco-flight-deck/infrastructure/issues/342) is already in the file above as the second `as` block. The agent never holds either token (the parent relays), so the credential-broker property the thread converged on survives with **zero** policy duplication. The difference between observe-the-cluster and roll-out-atlas is one word at launch: `--as agent-viewer` vs `--as deployer`. ## Residuals to get right (unchanged in substance, smaller in surface) - **`tier` on `grant` must be subset-only.** `grant` selects from `can run` by tier and can never name a verb or re-grant a `never run`. If a future `grant` references a tier no verb carries, that's a parse warning, not a silent empty grant. - **The fence is still the real boundary, not the syntax.** `drop-to` / `unshare` / `scrub-env` being complete is what keeps the token out of the child. Test the strip adversarially before trusting any tier tag above it. The red-team suite's sharpest assertion stays: *"no credential exists in the child's view"*. - **The file is the injectable surface.** It must be ward-owned and not writable by the agent uid, same discipline as the lockdown files. An agent that edits its own `tier=`/`grant` before the drop owns the session. - **RBAC stays the floor.** `agent-viewer` = built-in `view` (no Secrets, no exec). A `tier` bug still hits a 403. ## Concrete shape - **Keep `ward-kdl.kubectl.guardfile.kdl` as the single guardfile.** Add `tier=` to its verbs and the `as` / `fence` children to its `exec`. No separate read-only guardfile, no `supervise` grammar. - **Binary work in `ward-kdl`:** teach `exec` to read `as`/`mint`/`fence` children, add the relay backend, and select direct-vs-relay by exec ancestry. This is code, not a new dialect. - `deploy/agent-viewer-rbac.yml`: `ServiceAccount agent-viewer` + `ClusterRoleBinding` to built-in `view` (unchanged). - `docs/ward-kdl.kubectl.guardfile.md`: document `tier`, the `exec` children, and "the same wrap runs direct as a devtool or relayed as an exec-parent — mode is ancestry, not a keyword." **One-line version:** stop writing a supervisor spec. The supervisor only ever added "where's the credential" and "how's the child fenced," both of which are properties of `exec` — so nest them there, tag each existing `can run` with `tier=read|write` so the verb truth stays listed once, and let exec-ancestry (not a KDL block) decide whether ward runs the wrap directly for Kai or relays it for an agent. --- *Standard-depth research, grounded against the live `ward-kdl.kubectl.guardfile.kdl`. One-shot — verify before acting.* --- Researched and posted automatically by `ward agent claude reply` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent reply

ward agent claude reply ran a one-shot standard research pass on this question:

we are close but need another step. the gaurdfile at present has no -as- concept and it might not grow one. in fact im thinking about make the multiple roles story be about creating a unique binary for each agent operating mode that needs it. so this -as- and -tier- stuff belongs inside a ward design decision, one I am likely to decide against. this infra repo should assume a more narrow request: updating single binaries with the requested permissions story


Agreed — the binary is the role, and that empties most of this out of the infra repo

This is the right cut, and it's worth being explicit about why it's right rather than just smaller. Every prior step in this thread tried to express "which agent gets which powers" as runtime state — a token bound to an identity, an --as flag, a tier= tag, a grant line. Each of those is a multiplexer: one artifact that decides at launch which of several power-sets to assume. Multiplexers are exactly the thing you don't want load-bearing on a security boundary, because the selector (as, tier, the guardfile the agent might edit) becomes the attack surface. The whole earlier sprawl was the cost of keeping role-selection runtime.

Collapsing roles to separate binaries deletes the selector. There is nothing to choose at launch because the choice was made at build. ward-k8s-readonly physically does not contain an apply path, a deployer mint, or a write verb — not "configured not to," not present. That's the strong reading the thread floated early (the build-tagged variant) chosen as the whole answer, and it dissolves as/tier/grant/supervise rather than compressing them. Good. None of that vocabulary should exist.

The new contract is two halves in two repos

With role = binary, the design splits cleanly along the repo boundary you already have:

  • ward repo owns the binary's frozen permission story. Each operating mode is its own single-purpose build: the verb surface, the credential-acquisition path, the exec/fence behavior — all baked in, none of it runtime-selectable. The guardfile stays single-purpose with no -as- concept, exactly as you want. If a mode needs different powers, that's a different binary, not a flag.

  • infra repo (this repo) owns one RBAC identity per binary, and the wiring that pairs them. That's the entire narrow request. For each single-purpose binary, infra grants the single matching cluster scope and wires the right token to the process running that binary. Infra never encodes what verbs a binary allows — it can't see inside the binary and shouldn't try to mirror it. It grants the floor; the binary is the ceiling.

The pairing binary ↔ ServiceAccount is the contract surface between the two repos. ward says "this binary expects a view-class identity"; infra says "here is the agent-viewer SA bound to view." Neither repo restates the other's half, so there's nothing to drift.

What that makes #342's read-only sibling concretely

Stripped of all the supervisor/tier machinery, the infra deliverable is small and entirely in this repo's existing idiom:

  • deploy/agent-viewer-rbac.ymlServiceAccount agent-viewer + ClusterRoleBinding to the built-in view ClusterRole (get/list/watch, no Secrets, no exec/attach/portforward). Mirrors deploy/forgejo-runner-deploy.yml's shape. This is the RBAC half of the read-only binary, and it's unchanged from the first research pass — everything after that pass was the as/tier detour, which this decision discards.
  • No guardfiles/agent-k8s-readonly.kdl, no supervise block, no tier= edits to the kubectl guardfile. All of that was runtime-role plumbing. It evaporates.
  • The deploy half already exists: deployer SA + namespaced deployer Role (#342). It's just the RBAC identity paired with whatever ward-deploy-class binary the runner runs. Same pattern, different scope.
  • docs/cd-runner.md gets a short sibling (docs/agent-rbac.md) stating the contract: one binary, one SA, one scope; infra owns the SA and scope, ward owns the binary's frozen verbs; adding a mode means a new binary in ward + a new SA here, never a new flag.

The one residual the infra repo still has to get right: token delivery differs by topology, and the binary doesn't change that

Per-mode binaries fix what a process can do. They don't by themselves fix where the token lives, which is the in-process-bypass concern from earlier in the thread — and that's the part infra wires:

  • In-cluster (the #342 runner, deploy mode): the pod inherits its SA's projected token. The process is the trusted boundary because the pod identity is delivered by the kubelet, not held in a file the workload chose. Build-restriction + projected token compose cleanly. Nothing extra needed.
  • Out-of-cluster (Mac/laptop, read-only mode): there's no pod to inherit from, so the ward-k8s-readonly binary holds a minted view token itself. Here the build-restriction is doing real work: even though the process holds a token, the binary has no mutating path, and the view RBAC is the hard floor if the binary is bypassed entirely (someone curls the API with the token). That floor is why "read-only" survives even total binary compromise — and it's the reason infra binds to built-in view rather than a hand-rolled role that might forget to exclude Secrets/exec.

So the only thing infra must not get sloppy on: bind each binary's SA to the narrowest real RBAC scope, because that scope — not the binary — is the floor when the binary is bypassed. The binary raises the ceiling's quality (ergonomic, footgun-free, no mutation code); RBAC sets the floor. Keep both.

Net change to this issue

#342 itself is unaffected — the deployer runner is already a single-purpose identity by this model. What this decision does is close out the read-only sibling cleanly: it's now just deploy/agent-viewer-rbac.yml (SA + view binding) plus a one-paragraph contract doc, with all the guardfile-grammar work moved to the ward repo as "build a ward-k8s-readonly binary." If you want, the next concrete infra step is landing that RBAC manifest and filing the ward-side "single-purpose read-only binary" as its own issue against the ward repo, so the two halves track separately the way the contract says they should.

One-line version: stop encoding roles as runtime state anywhere — make each agent mode its own ward binary with a frozen verb set, and let this repo do the one narrow thing it's good at: grant each binary a single matching RBAC identity and wire its token, owning the floor while the binary owns the ceiling.


Researched and posted automatically by ward agent claude reply (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent reply `ward agent claude reply` ran a one-shot **standard** research pass on this question: > we are close but need another step. the gaurdfile at present has no -as- concept and it might not grow one. in fact im thinking about make the multiple roles story be about creating a unique binary for each agent operating mode that needs it. so this -as- and -tier- stuff belongs inside a ward design decision, one I am likely to decide against. this infra repo should assume a more narrow request: updating single binaries with the requested permissions story --- ## Agreed — the binary *is* the role, and that empties most of this out of the infra repo This is the right cut, and it's worth being explicit about *why* it's right rather than just smaller. Every prior step in this thread tried to express "which agent gets which powers" as **runtime** state — a token bound to an identity, an `--as` flag, a `tier=` tag, a `grant` line. Each of those is a multiplexer: one artifact that decides at launch which of several power-sets to assume. Multiplexers are exactly the thing you don't want load-bearing on a security boundary, because the selector (`as`, `tier`, the guardfile the agent might edit) becomes the attack surface. The whole earlier sprawl was the cost of keeping role-selection *runtime*. Collapsing roles to **separate binaries** deletes the selector. There is nothing to choose at launch because the choice was made at build. `ward-k8s-readonly` physically does not contain an `apply` path, a `deployer` mint, or a write verb — not "configured not to," *not present*. That's the strong reading the thread floated early (the build-tagged variant) chosen as the whole answer, and it dissolves `as`/`tier`/`grant`/`supervise` rather than compressing them. Good. None of that vocabulary should exist. ### The new contract is two halves in two repos With role = binary, the design splits cleanly along the repo boundary you already have: - **ward repo owns the binary's frozen permission story.** Each operating mode is its own single-purpose build: the verb surface, the credential-acquisition path, the exec/fence behavior — all baked in, none of it runtime-selectable. The guardfile stays single-purpose with no `-as-` concept, exactly as you want. If a mode needs different powers, that's a different binary, not a flag. - **infra repo (this repo) owns one RBAC identity per binary, and the wiring that pairs them.** That's the *entire* narrow request. For each single-purpose binary, infra grants the single matching cluster scope and wires the right token to the process running that binary. Infra never encodes *what verbs* a binary allows — it can't see inside the binary and shouldn't try to mirror it. It grants the floor; the binary is the ceiling. The pairing **binary ↔ ServiceAccount** is the contract surface between the two repos. ward says "this binary expects a `view`-class identity"; infra says "here is the `agent-viewer` SA bound to `view`." Neither repo restates the other's half, so there's nothing to drift. ### What that makes #342's read-only sibling concretely Stripped of all the supervisor/tier machinery, the infra deliverable is small and entirely in this repo's existing idiom: - `deploy/agent-viewer-rbac.yml` — `ServiceAccount agent-viewer` + `ClusterRoleBinding` to the built-in `view` ClusterRole (get/list/watch, no Secrets, no exec/attach/portforward). Mirrors `deploy/forgejo-runner-deploy.yml`'s shape. This is the RBAC half of the read-only binary, and it's unchanged from the first research pass — everything *after* that pass was the `as`/`tier` detour, which this decision discards. - **No `guardfiles/agent-k8s-readonly.kdl`, no `supervise` block, no `tier=` edits to the kubectl guardfile.** All of that was runtime-role plumbing. It evaporates. - The deploy half already exists: `deployer` SA + namespaced `deployer` Role (#342). It's just the RBAC identity paired with whatever `ward-deploy`-class binary the runner runs. Same pattern, different scope. - `docs/cd-runner.md` gets a short sibling (`docs/agent-rbac.md`) stating the contract: **one binary, one SA, one scope; infra owns the SA and scope, ward owns the binary's frozen verbs; adding a mode means a new binary in ward + a new SA here, never a new flag.** ### The one residual the infra repo still has to get right: token delivery differs by topology, and the binary doesn't change that Per-mode binaries fix *what a process can do*. They don't by themselves fix *where the token lives*, which is the in-process-bypass concern from earlier in the thread — and that's the part infra wires: - **In-cluster (the #342 runner, deploy mode):** the pod inherits its SA's projected token. The process *is* the trusted boundary because the pod identity is delivered by the kubelet, not held in a file the workload chose. Build-restriction + projected token compose cleanly. Nothing extra needed. - **Out-of-cluster (Mac/laptop, read-only mode):** there's no pod to inherit from, so the `ward-k8s-readonly` binary holds a minted `view` token itself. Here the build-restriction is doing real work: even though the process holds a token, the binary has no mutating path, and the `view` RBAC is the hard floor if the binary is bypassed entirely (someone `curl`s the API with the token). That floor is why "read-only" survives even total binary compromise — and it's the reason infra binds to built-in `view` rather than a hand-rolled role that might forget to exclude Secrets/exec. So the only thing infra must not get sloppy on: **bind each binary's SA to the narrowest real RBAC scope, because that scope — not the binary — is the floor when the binary is bypassed.** The binary raises the ceiling's quality (ergonomic, footgun-free, no mutation code); RBAC sets the floor. Keep both. ### Net change to this issue #342 itself is unaffected — the `deployer` runner is already a single-purpose identity by this model. What this decision does is **close out the read-only sibling cleanly**: it's now just `deploy/agent-viewer-rbac.yml` (SA + `view` binding) plus a one-paragraph contract doc, with all the guardfile-grammar work moved to the ward repo as "build a `ward-k8s-readonly` binary." If you want, the next concrete infra step is landing that RBAC manifest and filing the ward-side "single-purpose read-only binary" as its own issue against the ward repo, so the two halves track separately the way the contract says they should. **One-line version:** stop encoding roles as runtime state anywhere — make each agent mode its own ward binary with a frozen verb set, and let this repo do the one narrow thing it's good at: grant each binary a single matching RBAC identity and wire its token, owning the floor while the binary owns the ceiling. --- Researched and posted automatically by `ward agent claude reply` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔎 ward agent reply

ward agent claude reply ran a one-shot standard research pass on this question:

mild flip flop... the infra repo -should- own the KDL, its just that it should own a distinct KDL spec for every operational mode. which in this case means that the infra repo holds a gaurdfile - or several, actually - for each distinct least privilege ops roles. or maybe just 1 big readonly role. or maybe 2 roles with adversarial validation. IDK. the idea is that the infra repo does in fact get to tell ward what it wants, its just that the infra repo doesnt get to define net new concepts beyond the ones strictly required to enforce the k8s creds access controls


Yes — infra authors the policy, ward owns the language. That's not a flip-flop, it's the missing distinction.

The thing that kept thrashing across this thread is that "owns the KDL" conflated two different ownerships. Split them and your instinct here is exactly right with no contradiction to the binary-per-mode decision:

  • ward owns the vocabulary and the dictionary. The KDL grammar itself, plus the canonical wrap ward-kdl ops kubectl capability definition — what verbs exist, how each verb's args are disciplined, and the exec/credential-broker mechanism that enforces any of it. These are concepts.
  • infra owns the policy. One guardfile per operational mode, each written in ward's vocabulary, selecting a least-privilege subset of the dictionary. These are values.

So infra "gets to tell ward what it wants" by handing it a role guardfile — infra is a consumer of the DSL, never a co-author of it. The rule you're drawing is the consumer/extender line:

Infra guardfiles may select, narrow, and deny using keywords ward already defines. The moment a role needs a keyword that isn't already in the kubectl dictionary, that's a ward design decision — file it against ward, don't mint it in the infra guardfile.

That rule is what kills as / tier / supervise / credential / mint / fence for good. Every one of those was a net-new concept I smuggled into the policy layer. They don't belong in infra at all — some belong in ward (the exec/broker mechanism), and the rest (as, tier) are runtime role-multiplexers you've already decided against by going binary-per-mode. With one binary pinned to one role guardfile, there is nothing to select at runtime, so the multiplexing vocabulary simply isn't required to enforce creds access control. It fails your test and drops out.

What a role guardfile is allowed to contain

Strictly the surface that is the k8s creds access control, expressed in existing wrap/can run/never run keys:

  • the verb allowlist for this role (can run get, can run logs, …),
  • arg discipline by reference to the dictionary (the definition of what get's flags may be stays in ward; the role names the verb, not its internals),
  • namespace scoping if the role is ns-bound (the read-only role is cluster-wide-read; a deploy role is atlas-only),
  • explicit never run for the escape primitives, as belt-and-suspenders even though they're absent from the allowlist (never run exec, never run port-forward, never run delete).

Nothing else. No credential source (that's ward's broker mechanism + the SA you wire), no exec-strip syntax (ward's runtime), no role selector (the binary is the selector).

The one ward primitive that keeps this from duplicating the dictionary

The real risk in "one guardfile per role" is that each role re-states can run get { describe "…"; allow-args … } and now the arg discipline for get lives in N places and drifts. To avoid that, infra role files must be able to select a verb by name and inherit its discipline from the canonical kubectl wrap — can run get (bare), not a re-declared block. Whether that select-by-reference exists is a ward capability question (it's a ward concept, owned by ward, so it's fair game). If ward has it, every role guardfile is pure value-selection on top of the dictionary. If ward declines it, infra falls back to full restatement — uglier, but still inventing zero new concepts. I'd file "let a guardfile only/select a subset of an existing wrap by verb name" as the single ward-side ask that makes infra-owned role files clean.

And note the upside of deny-by-default here: if ward later adds a verb to the dictionary, role files don't auto-inherit it. For least-privilege that's correct — a new capability shouldn't silently widen an existing role. Non-inheritance is the feature.

1 role, 2 roles, or N? — build only the roles with a live consumer

Don't speculate the role catalog. Build exactly the modes that exist today:

  • agent-k8s-readonly — "1 big readonly role" is the right call. All read verbs (get/describe/logs/top/events/explain/api-resources/version), cluster-wide, never run on every mutator and escape primitive. This is the #342 read-only sibling.
  • deploy-atlas#342's deployer: apply/scale/rollout status, scoped to atlas. Already has its RBAC half.

That's your "2 roles." The adversarial validation you floated is the right framing for how you trust them, not a third role: a checked-in red-team target per guardfile that fires the breakout matrix (arg-smuggling -f -/--as/--raw, mutator verbs, exec/port-forward) and asserts each role can't exceed its declared verb set — backstopped by the RBAC floor so a guardfile bug still hits a 403. The readonly role's sharpest assertion stays "the bound token is view, so even a total guardfile bypass yields reads minus Secrets." Two least-privilege roles, each adversarially validated against its own ceiling and the RBAC floor beneath it — that's the shape, and it's better than splitting readonly into two half-roles.

How this composes with the binary-per-mode decision (it doesn't undo it)

The last decision still holds, refined: each single-purpose binary is the binary pinned to exactly one infra-owned role guardfile. No --as, no runtime role state. infra now owns two halves of each mode instead of one:

  • the role guardfile (what verbs/args/namespaces — ward's vocabulary, infra's values),
  • the RBAC identity (ServiceAccount + binding — the cluster floor).

ward owns the language the guardfile is written in and the runtime that enforces it (exec strip, credential broker, arg discipline). The contract surface between the repos becomes binary ↔ {role guardfile, ServiceAccount}: infra declares the ceiling (guardfile) and the floor (RBAC); ward provides the engine. Neither repo restates the other's half, nothing drifts.

Concrete shape for this repo

  • guardfiles/agent-k8s-readonly.kdl — read-verb allowlist + never run escapes, in ward's existing wrap/can run/never run vocabulary, selecting from the canonical kubectl dictionary. No new keywords.
  • guardfiles/deploy-atlas.kdlapply/scale/rollout status, ns atlas. Same vocabulary.
  • deploy/agent-viewer-rbac.ymlServiceAccount agent-viewer + ClusterRoleBinding to built-in view (get/list/watch, no Secrets, no exec). The floor under the readonly guardfile. Mirrors deploy/forgejo-runner-deploy.yml.
  • deploy/deployer-rbac.yml#342's deployer SA + namespaced Role (already in flight).
  • A checked-in red-team target per guardfile (the adversarial validation), asserting verb-set containment + the RBAC floor.
  • docs/cd-runner.md sibling docs/agent-guardfiles.md stating the contract: ward owns the vocabulary and dictionary; infra owns one role guardfile + one RBAC identity per mode, using only ward-defined keywords; a role that needs a new keyword is a ward issue, not an infra guardfile edit.
  • ward-side ask (separate issue against the ward repo): select-a-verb-subset-by-reference, so role guardfiles narrow the canonical kubectl wrap instead of restating its arg discipline.

One-line version: the flip-flop resolves cleanly once you separate "owns the KDL language" (ward) from "owns the policy written in it" (infra) — infra authors one least-privilege role guardfile plus its matching RBAC per operational mode, expressed entirely in ward's existing can run/never run vocabulary, and the iron rule is that infra picks values but never invents a keyword; a role that can't be expressed in ward's current vocabulary is a ward design decision, not an infra extension.


Researched and posted automatically by ward agent claude reply (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent reply `ward agent claude reply` ran a one-shot **standard** research pass on this question: > mild flip flop... the infra repo -should- own the KDL, its just that it should own a distinct KDL spec for every operational mode. which in this case means that the infra repo holds a gaurdfile - or several, actually - for each distinct least privilege ops roles. or maybe just 1 big readonly role. or maybe 2 roles with adversarial validation. IDK. the idea is that the infra repo does in fact get to tell ward what it wants, its just that the infra repo doesnt get to define net new concepts beyond the ones strictly required to enforce the k8s creds access controls --- ## Yes — infra authors the policy, ward owns the language. That's not a flip-flop, it's the missing distinction. The thing that kept thrashing across this thread is that "owns the KDL" conflated two different ownerships. Split them and your instinct here is exactly right with no contradiction to the binary-per-mode decision: - **ward owns the *vocabulary* and the *dictionary*.** The KDL grammar itself, plus the canonical `wrap ward-kdl ops kubectl` capability definition — what verbs exist, how each verb's args are disciplined, and the exec/credential-broker mechanism that enforces any of it. These are **concepts**. - **infra owns the *policy*.** One guardfile per operational mode, each written *in ward's vocabulary*, selecting a least-privilege subset of the dictionary. These are **values**. So infra "gets to tell ward what it wants" by handing it a role guardfile — infra is a **consumer** of the DSL, never a co-author of it. The rule you're drawing is the consumer/extender line: > **Infra guardfiles may select, narrow, and deny using keywords ward already defines. The moment a role needs a keyword that isn't already in the kubectl dictionary, that's a ward design decision — file it against ward, don't mint it in the infra guardfile.** That rule is what kills `as` / `tier` / `supervise` / `credential` / `mint` / `fence` for good. Every one of those was a *net-new concept* I smuggled into the policy layer. They don't belong in infra at all — some belong in ward (the exec/broker mechanism), and the rest (`as`, `tier`) are runtime role-multiplexers you've already decided against by going binary-per-mode. With one binary pinned to one role guardfile, there is nothing to select at runtime, so the multiplexing vocabulary simply isn't required to enforce creds access control. It fails your test and drops out. ## What a role guardfile is allowed to contain Strictly the surface that *is* the k8s creds access control, expressed in existing `wrap`/`can run`/`never run` keys: - **the verb allowlist** for this role (`can run get`, `can run logs`, …), - **arg discipline** *by reference* to the dictionary (the definition of what `get`'s flags may be stays in ward; the role names the verb, not its internals), - **namespace scoping** if the role is ns-bound (the read-only role is cluster-wide-read; a deploy role is `atlas`-only), - **explicit `never run`** for the escape primitives, as belt-and-suspenders even though they're absent from the allowlist (`never run exec`, `never run port-forward`, `never run delete`). Nothing else. No credential source (that's ward's broker mechanism + the SA you wire), no exec-strip syntax (ward's runtime), no role selector (the binary is the selector). ## The one ward primitive that keeps this from duplicating the dictionary The real risk in "one guardfile per role" is that each role re-states `can run get { describe "…"; allow-args … }` and now the arg discipline for `get` lives in N places and drifts. To avoid that, infra role files must be able to **select a verb by name and inherit its discipline** from the canonical kubectl wrap — `can run get` (bare), not a re-declared block. Whether that select-by-reference exists is a **ward** capability question (it's a ward concept, owned by ward, so it's fair game). If ward has it, every role guardfile is pure value-selection on top of the dictionary. If ward declines it, infra falls back to full restatement — uglier, but still inventing zero new concepts. I'd file "let a guardfile `only`/select a subset of an existing wrap by verb name" as the single ward-side ask that makes infra-owned role files clean. And note the upside of deny-by-default here: if ward later adds a verb to the dictionary, role files *don't* auto-inherit it. For least-privilege that's correct — a new capability shouldn't silently widen an existing role. Non-inheritance is the feature. ## 1 role, 2 roles, or N? — build only the roles with a live consumer Don't speculate the role catalog. Build exactly the modes that exist today: - **`agent-k8s-readonly`** — "1 big readonly role" is the right call. All read verbs (`get`/`describe`/`logs`/`top`/`events`/`explain`/`api-resources`/`version`), cluster-wide, `never run` on every mutator and escape primitive. This is the #342 read-only sibling. - **`deploy-atlas`** — #342's `deployer`: `apply`/`scale`/`rollout status`, scoped to `atlas`. Already has its RBAC half. That's your "2 roles." The **adversarial validation** you floated is the right framing for *how you trust them*, not a third role: a checked-in red-team target per guardfile that fires the breakout matrix (arg-smuggling `-f -`/`--as`/`--raw`, mutator verbs, exec/port-forward) and asserts each role can't exceed its declared verb set — backstopped by the RBAC floor so a guardfile bug still hits a 403. The readonly role's sharpest assertion stays "the bound token is `view`, so even a total guardfile bypass yields reads minus Secrets." Two least-privilege roles, each adversarially validated against its own ceiling and the RBAC floor beneath it — that's the shape, and it's better than splitting readonly into two half-roles. ## How this composes with the binary-per-mode decision (it doesn't undo it) The last decision still holds, refined: **each single-purpose binary is the binary pinned to exactly one infra-owned role guardfile.** No `--as`, no runtime role state. infra now owns *two* halves of each mode instead of one: - the **role guardfile** (what verbs/args/namespaces — ward's vocabulary, infra's values), - the **RBAC identity** (`ServiceAccount` + binding — the cluster floor). ward owns the **language** the guardfile is written in and the **runtime** that enforces it (exec strip, credential broker, arg discipline). The contract surface between the repos becomes **binary ↔ {role guardfile, ServiceAccount}**: infra declares the ceiling (guardfile) and the floor (RBAC); ward provides the engine. Neither repo restates the other's half, nothing drifts. ## Concrete shape for this repo - `guardfiles/agent-k8s-readonly.kdl` — read-verb allowlist + `never run` escapes, in ward's existing `wrap`/`can run`/`never run` vocabulary, selecting from the canonical kubectl dictionary. **No new keywords.** - `guardfiles/deploy-atlas.kdl` — `apply`/`scale`/`rollout status`, ns `atlas`. Same vocabulary. - `deploy/agent-viewer-rbac.yml` — `ServiceAccount agent-viewer` + `ClusterRoleBinding` to built-in `view` (get/list/watch, no Secrets, no exec). The floor under the readonly guardfile. Mirrors `deploy/forgejo-runner-deploy.yml`. - `deploy/deployer-rbac.yml` — #342's `deployer` SA + namespaced Role (already in flight). - A checked-in red-team target per guardfile (the adversarial validation), asserting verb-set containment + the RBAC floor. - `docs/cd-runner.md` sibling `docs/agent-guardfiles.md` stating the contract: **ward owns the vocabulary and dictionary; infra owns one role guardfile + one RBAC identity per mode, using only ward-defined keywords; a role that needs a new keyword is a ward issue, not an infra guardfile edit.** - **ward-side ask (separate issue against the ward repo):** select-a-verb-subset-by-reference, so role guardfiles narrow the canonical kubectl wrap instead of restating its arg discipline. **One-line version:** the flip-flop resolves cleanly once you separate "owns the KDL language" (ward) from "owns the policy written in it" (infra) — infra authors one least-privilege role guardfile plus its matching RBAC per operational mode, expressed entirely in ward's existing `can run`/`never run` vocabulary, and the iron rule is that infra picks values but never invents a keyword; a role that can't be expressed in ward's current vocabulary is a ward design decision, not an infra extension. --- Researched and posted automatically by `ward agent claude reply` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-infrastructure-342 on host kais-macbook-pro-2.local is carrying this issue (reserved 2026-07-01T08:08:41Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-infrastructure-342` on host `kais-macbook-pro-2.local` is carrying this issue (reserved 2026-07-01T08:08:41Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - CD deploy runner was already fully landed on main; synced the blast-radius docs to the shipped RBAC and closed the issue.

Honest version: I came in expecting to build the runner and found it already standing. The StatefulSet, the deployer SA + RBAC, the home-IP ExternalSecret, the tool-bearing image (Dockerfile + build-runner-deploy workflow), and the whole docs/cd-runner.md writeup had all shipped to main across 9fb602f and its follow-ups. The catch: every one of those commits used a Refs #342 trailer, never a closes, so the issue just sat open long after the work was done. The issue-342 branch had a zero-byte diff against main.

The only genuine gap was doc drift, and it fought back the way stale docs always do, quietly. The deployer SA had grown Roles in coilysiren-eco-app and open-webui since the prose was written, but cd-runner.md still named two namespaces and FEATURES.md said "scoped to the atlas namespace" full stop. So the finishing commit that actually carries closes #342 is a doc sync to match the manifest, not new infra.

One surprise unrelated to the feature: the pylint pre-commit hook died on /opt/uv being root-owned in this container (Permission denied creating the uv python dir). Worked around with UV_PYTHON_INSTALL_DIR pointed at $HOME, and the full gate went green, no --no-verify.

Confidence: high that the docs now match the shipped RBAC and the issue is legitimately closed. I could not verify the live runner from here (no cluster access), so I'm trusting the earlier comments that it registered with deploy:host and rolled atlas. Follow-ups worth their own issues, not this one: the pre-flight NO-GO's real remainder still lives across the wall in coilyco-bridge/deploy (re-enabling the push trigger on deploy-atlas.yml), and someone on-cluster should spot-check that the runner still registers after the recent image/RBAC churn.

WARD-OUTCOME: done - CD deploy runner was already fully landed on main; synced the blast-radius docs to the shipped RBAC and closed the issue. Honest version: I came in expecting to build the runner and found it already standing. The StatefulSet, the `deployer` SA + RBAC, the home-IP ExternalSecret, the tool-bearing image (Dockerfile + build-runner-deploy workflow), and the whole `docs/cd-runner.md` writeup had all shipped to `main` across `9fb602f` and its follow-ups. The catch: every one of those commits used a `Refs #342` trailer, never a `closes`, so the issue just sat open long after the work was done. The `issue-342` branch had a zero-byte diff against `main`. The only genuine gap was doc drift, and it fought back the way stale docs always do, quietly. The `deployer` SA had grown Roles in `coilysiren-eco-app` and `open-webui` since the prose was written, but `cd-runner.md` still named two namespaces and `FEATURES.md` said "scoped to the atlas namespace" full stop. So the finishing commit that actually carries `closes #342` is a doc sync to match the manifest, not new infra. One surprise unrelated to the feature: the `pylint` pre-commit hook died on `/opt/uv` being root-owned in this container (`Permission denied` creating the uv python dir). Worked around with `UV_PYTHON_INSTALL_DIR` pointed at `$HOME`, and the full gate went green, no `--no-verify`. Confidence: high that the docs now match the shipped RBAC and the issue is legitimately closed. I could not verify the *live* runner from here (no cluster access), so I'm trusting the earlier comments that it registered with `deploy:host` and rolled atlas. Follow-ups worth their own issues, not this one: the pre-flight NO-GO's real remainder still lives across the wall in `coilyco-bridge/deploy` (re-enabling the push trigger on `deploy-atlas.yml`), and someone on-cluster should spot-check that the runner still registers after the recent image/RBAC churn.
Sign in to join this conversation.
No description provided.