CD: dedicated forgejo deploy runner (in-cluster ServiceAccount) #342
Labels
No labels
burndown-2026-06
coherence-core
consult
headless
interactive
P0
P1
P2
P3
P4
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
coilyco-flight-deck/infrastructure#342
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.deploy:host-> jobs run in host mode in the runner pod, inheriting thedeployerServiceAccount token (in-cluster kubectl/helm, no kubeconfig secret, short-lived projected token).ClusterRole deployer-namespaces(namespaces get/create) + namespacedRole deployerin theatlasnamespace only. Migrating another service adds a RoleBinding in its ns./coilysiren/home/public-ip) into the runner asHOME_PUBLIC_IPvia 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 thedeploylabel) and a dispatch of deploy-atlas rolls out atlas.Update: runner is up, but host-mode jobs need a tool-bearing image
forgejo-runner-deploy-0registered fine (labeldeploy, host mode) and picked up the firstdeploy-atlasrun - which failed in ~5s. Root cause: host-mode jobs run inside the stockdata.forgejo.org/forgejo/runner:12image, which has nonode, soactions/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:
Push it to the in-cluster registry, set it as the
runnercontainer image indeploy/forgejo-runner-deploy.yml, re-apply. Then incoilyco-bridge/deployre-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
deploy-atlasworkflow: committed, dispatch-only so it does not auto-fail.rollout.sh- CD is the automation layer, not a blocker for atlas being live.🛫 ward pre-flight: NO-GO
ward agent claude headlessran 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.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-preflightskips 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 separatecoilyco-bridge/deployrepo. An ephemeral container with a fresh clone ofinfrastructurehas no k3s cluster, no in-cluster registry, nosudo, 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 headlesspre-flight (ward#147, ward#149).— Claude (she/her), via
ward agent🔎 ward agent reply
ward agent claude replyran a one-shot standard research pass on this question: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 (
deployerSA, 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
kubectlcan 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
A read-only cluster identity. A
ServiceAccount agent-viewer(own namespace, sayagents) bound viaClusterRoleBindingto the built-inviewClusterRole.viewis the right default for two reasons: it isget/list/watchonly, and it deliberately excludes Secrets. It also does not grantpods/exec,pods/attach, orpods/portforward, which are escape primitives, not reads. If a custom ClusterRole is used instead ofview, those exclusions have to be reproduced by hand. Mirror the manifest shape already indeploy/forgejo-runner-deploy.yml, a newdeploy/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 thedeployerRole is if Kai wants tighter blast radius on secret-adjacent configmaps.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=30mon kai-server, wrapped behind a ward verb. Two delivery shapes:ward k8s-tokenmint verb. The 30m expiry is the safety net behind the RBAC boundary.kai-serverSSH 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.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.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 inward.yamldefaults. The mint verb writes a kubeconfig to a runtime path, not the repo.The readonly sub-build of ward. Two readings of "sub-build", and the stronger one matters because of the principle above:
ward.yaml(k8s-get,k8s-describe,k8s-logs,k8s-top,k8s-api-resources) each shelling to akubectlinvocation, with the lockdown continuing to deny barekubectl. Fast to ship, but the verbs must arg-allowlist (reject-f,--as,exec,patch, mutating subcommands) because a permissiverun:is smugglable.apply/delete/patchbecause 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+ClusterRoleBindingto built-inview. One apply, mirrors #342's pattern.ward k8s-tokenverb (runs on kai-server) that mints a 30mviewtoken and writes a runtime kubeconfig with the SSM-resolved endpoint/CA.ward k8s ...command group on the agent side, ideally the build-tagged variant, arg-allowlisted toget/list/describe/logs/top/api-resources/explain, pointed at that kubeconfig.kubectl/helmso the only path to the cluster stays the read-only verb group.What this does not need
No write RBAC, no namespace
create, noexec/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 replyran a one-shot standard research pass on this question: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":
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,
curlthe 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:kubectl get -f -,--as/--as-group,exec,attach,port-forward,proxy,--raw,cp, and the sneaky ones (-o go-template/jsonpaththat shell out,--token/--serveroverrides). Expected: denied by the verb's arg-allowlist.ward.yamland invoke it. Expected: denied by lockdown, and even if it lands, denied by RBAC..claude/lockdown-deny.sh/settings.jsonfrom inside. Expected: refused, and surfaced.curlthe API with the kubeconfig token. Expected: allowed but harmless (RBACviewonly). This test exists precisely to document that ward is bypassable by design and that the RBAC floor holds anyway.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-viewertoken did onlyget/list/watch, touched no Secrets, neverexec/port-forward. Add drift detection onward.yamland 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/execpaths 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:
deployerwrite 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
docs/ward-trust-model.md(sibling ofdocs/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.deploy/agent-viewer-rbac.yml(viewClusterRole, no Secrets, no exec) and theward k8s-tokenmint verb, exactly as the prior research laid out.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 replyran a one-shot standard research pass on this question: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
curlwith. 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 (thinkssh-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:
ward-brokercontainer 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 anemptyDir. 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.agent, run a smallward-brokerdaemon as uidward, kubeconfig0600owned byward. Filesystem perms keepagentout. This works but is weaker: same PID namespace means you must dropCAP_SYS_PTRACEand never let the agent process run as root, or it reads the broker's memory. Prefer separate containers when you can.viewtoken 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:get pods -n atlas,logs deploy/x) and constructs the kubectl/API call itself.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:
ward k8s get pods -n atlasbecomes a thin stub that dials the socket. Same verb surface Kai already wants, no kubeconfig in sight.ward k8s-tokenmint verb the agent calls, no kubeconfig handoff, no refresh dance.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:
view-only policy. Even total agent compromise yields reads, minus Secrets.deployerwrite 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
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.Concrete first shape for this repo
deploy/agent-viewer-rbac.yml:ServiceAccount agent-viewer+ClusterRoleBindingto built-inview(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 theviewtoken into its namespace only, exposing a UDS on a sharedemptyDir. Mirrorsdeploy/forgejo-runner-deploy.yml's shape.ward k8s ...becomes a UDS client stub, no token, no kubeconfig.docs/cd-runner.mdsiblingdocs/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 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 replyran a one-shot standard research pass on this question: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:agent-viewertoken (or resolves it from kai-server), and holds it in its own process memory only. Nothing on disk for a child to find.socketpair) that will cross the exec, then forks.curlwith.ward k8s get ...is a thin client that speaks over that fd to the supervising ward-kdl. It is not a freshwardprocess 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.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.yamlnever 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):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. Thedeny-args/deny-verblines 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
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.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:
/proc/<parent>) — expected: nothing exists, and/proc/<parent>/memis 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.deny-args/deny-verblines.agent-viewerSA is stillview-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.view-only invariant through a channel neither ward-kdl nor the agent controls.The unifying payoff carries over intact
Same architecture, different guard policy:
deployerwrite path later = the samesessionblock with acredentialminting thedeployertoken and aguard "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
KUBECONFIG, an inherited fd, a readable/proc/<parent>/membecauseCAP_SYS_PTRACEwasn't dropped), the guardfile is decorative. Test the strip adversarially before trusting any guard block above it.k3s kubectl create tokenor 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+ClusterRoleBindingto the built-inviewClusterRole (no Secrets, no exec). Mirrorsdeploy/forgejo-runner-deploy.yml's shape, unchanged from prior research.guardfiles/agent-k8s-readonly.kdl: thesessionblock above. The credential mint, the exec strip, and the semantick8sguard in one file.ward k8s ...stub: a control-fd client, no kubeconfig, no mint verb on the agent side.docs/ward-kdl-broker.md(sibling ofdocs/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 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 replyran a one-shot standard research pass on this question: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: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 samecan run/never runtruth 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-kdlshould run in two modes that share everything except where the privilegedexeclands:ward-kdl ops kubectl get pods -n atlas. Parses argv, checks it against thewrapallowlist, then execs the fixed localkubectlagainst the ambient kubeconfig. This is today's behavior on your host, where you're the trusted operator.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 isward-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:
execis a backend, selected by exec ancestryexec kubectlin the wrap is a capability, not a hardcoded local fork. Resolve it through whichever exec backend is live:kubectlagainst the ambient kubeconfig.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'sexecresolves to, and that's set by howward-kdlwas 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:
get,logs,never run deleteare defined once, in the wrap. The supervisoronly-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 ifonlywere wrong, theagent-viewertoken isview-RBAC, soapplyhits a 403 floor.Read-only now / deploy later is the same supervisor block, two knobs
minttheagent-viewer(view) token,onlythe read subset above.deployer): samesuperviseblock,mint deployer,only get describe logs apply scale rollout. The base wrap is still the single source of truth for whatapply/scale/rolloutmean and how their args are disciplined. Only the token and theonlyline 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
ward-kdl ops kubectlautomatically updates every supervised session that exposes it. No second list to drift.Residual things to get right
onlymust be subset-checked against the wrap at parse, and can never widen it. It selects fromcan run, it never introduces a verb or re-grants anever run. Ifonlynames something the wrap doesn't expose, that's a parse error, not a silent grant.only, never from the child's framing.k3s kubectl create tokenor 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+ClusterRoleBindingto built-inview(unchanged).ward-kdl.kubectl.guardfile.kdlas-is — it's the verb surface for both modes.superviseblock +expose … onlycomposition + the relay exec backend toward-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.mdsiblingdocs/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
getordeletemean — 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 anonly-narrowedexposeof 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 replyran a one-shot standard research pass on this question:One file, one
wrap— the supervisor facts hang off keys that already existYou're right, and the tell is exactly what you named:
supervise,credential,exec(re-defined),guard,verb,expose,onlywere a second KDL dialect sitting next towrap/exec/can run/never runand 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:
execruns.execruns.Everything else I invented (
guard,verb,expose … only) was re-encoding the verb truth thatcan run/never runalready 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.kdlas-is and make three small, in-vocabulary additions. No second file, no second grammar:That's the whole thing.
wrap,exec,can run,never runare the same keys the devtool already uses. The additions are: atier=property on verbs that already exist, andas/mint/endpoint/grant/fenceas children ofexec— 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=writeto 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.tieris 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 underexec. A reader scanning the file sees onewrap, 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/fencenever fire. This is today's behavior, byte-for-byte.ward-kdl run --as agent-viewer -- claude …→ ward is the exec-parent → it reads the sameas agent-viewerblock, mints and holds the token in its own memory, appliesfence, drops, and execs the child whose only cluster path isward-kdl ops kubectlrelayed back to the parent.The child cannot pick
--asor 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 fromgrant readbinding the mint totier=read, backstopped by theagent-viewerRBAC floor.Read-only now, deploy later — same file, one launch flag
Nothing forks. The deploy path (the
deployeridentity from #342) is already in the file above as the secondasblock. 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-viewervs--as deployer.Residuals to get right (unchanged in substance, smaller in surface)
tierongrantmust be subset-only.grantselects fromcan runby tier and can never name a verb or re-grant anever run. If a futuregrantreferences a tier no verb carries, that's a parse warning, not a silent empty grant.drop-to/unshare/scrub-envbeing 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".tier=/grantbefore the drop owns the session.agent-viewer= built-inview(no Secrets, no exec). Atierbug still hits a 403.Concrete shape
ward-kdl.kubectl.guardfile.kdlas the single guardfile. Addtier=to its verbs and theas/fencechildren to itsexec. No separate read-only guardfile, nosupervisegrammar.ward-kdl: teachexecto readas/mint/fencechildren, 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+ClusterRoleBindingto built-inview(unchanged).docs/ward-kdl.kubectl.guardfile.md: documenttier, theexecchildren, 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 existingcan runwithtier=read|writeso 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 replyran a one-shot standard research pass on this question: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
--asflag, atier=tag, agrantline. 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-readonlyphysically does not contain anapplypath, adeployermint, 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 dissolvesas/tier/grant/superviserather 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 theagent-viewerSA bound toview." 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+ClusterRoleBindingto the built-inviewClusterRole (get/list/watch, no Secrets, no exec/attach/portforward). Mirrorsdeploy/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 theas/tierdetour, which this decision discards.guardfiles/agent-k8s-readonly.kdl, nosuperviseblock, notier=edits to the kubectl guardfile. All of that was runtime-role plumbing. It evaporates.deployerSA + namespaceddeployerRole (#342). It's just the RBAC identity paired with whateverward-deploy-class binary the runner runs. Same pattern, different scope.docs/cd-runner.mdgets 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:
ward-k8s-readonlybinary holds a mintedviewtoken itself. Here the build-restriction is doing real work: even though the process holds a token, the binary has no mutating path, and theviewRBAC is the hard floor if the binary is bypassed entirely (someonecurls 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-inviewrather 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
deployerrunner is already a single-purpose identity by this model. What this decision does is close out the read-only sibling cleanly: it's now justdeploy/agent-viewer-rbac.yml(SA +viewbinding) plus a one-paragraph contract doc, with all the guardfile-grammar work moved to the ward repo as "build award-k8s-readonlybinary." 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 replyran a one-shot standard research pass on this question: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:
wrap ward-kdl ops kubectlcapability 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.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:
That rule is what kills
as/tier/supervise/credential/mint/fencefor 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 runkeys:can run get,can run logs, …),get's flags may be stays in ward; the role names the verb, not its internals),atlas-only),never runfor 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 forgetlives 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 guardfileonly/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 runon every mutator and escape primitive. This is the #342 read-only sibling.deploy-atlas— #342'sdeployer:apply/scale/rollout status, scoped toatlas. 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 isview, 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: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 runescapes, in ward's existingwrap/can run/never runvocabulary, selecting from the canonical kubectl dictionary. No new keywords.guardfiles/deploy-atlas.kdl—apply/scale/rollout status, nsatlas. Same vocabulary.deploy/agent-viewer-rbac.yml—ServiceAccount agent-viewer+ClusterRoleBindingto built-inview(get/list/watch, no Secrets, no exec). The floor under the readonly guardfile. Mirrorsdeploy/forgejo-runner-deploy.yml.deploy/deployer-rbac.yml— #342'sdeployerSA + namespaced Role (already in flight).docs/cd-runner.mdsiblingdocs/agent-guardfiles.mdstating 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.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 runvocabulary, 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🔒 Reserved by
ward agent --driver claude— containerengineer-claude-infrastructure-342on hostkais-macbook-pro-2.localis carrying this issue (reserved 2026-07-01T08:08:41Z). Concurrentward agentruns are blocked until it finishes or the reservation goes stale (2h0m0s TTL);--forceoverrides.— Claude (she/her), via
ward agentWARD-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
deployerSA + RBAC, the home-IP ExternalSecret, the tool-bearing image (Dockerfile + build-runner-deploy workflow), and the wholedocs/cd-runner.mdwriteup had all shipped tomainacross9fb602fand its follow-ups. The catch: every one of those commits used aRefs #342trailer, never acloses, so the issue just sat open long after the work was done. Theissue-342branch had a zero-byte diff againstmain.The only genuine gap was doc drift, and it fought back the way stale docs always do, quietly. The
deployerSA had grown Roles incoilysiren-eco-appandopen-webuisince the prose was written, butcd-runner.mdstill named two namespaces andFEATURES.mdsaid "scoped to the atlas namespace" full stop. So the finishing commit that actually carriescloses #342is a doc sync to match the manifest, not new infra.One surprise unrelated to the feature: the
pylintpre-commit hook died on/opt/uvbeing root-owned in this container (Permission deniedcreating the uv python dir). Worked around withUV_PYTHON_INSTALL_DIRpointed 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:hostand rolled atlas. Follow-ups worth their own issues, not this one: the pre-flight NO-GO's real remainder still lives across the wall incoilyco-bridge/deploy(re-enabling the push trigger ondeploy-atlas.yml), and someone on-cluster should spot-check that the runner still registers after the recent image/RBAC churn.