harden(explore): root broker holds the bot token, agent gets ward-kdl-write via the broker, no push (supersedes ward#318) #329

Closed
opened 2026-06-25 09:27:39 +00:00 by coilyco-ops · 4 comments
Member

The reframe

"Read-only" for warded explore is about the git source tree (this clone can't push commits), not the Forgejo API. A real infra / deploy / ser8 explorer that can't file an issue, flip a label, cut a release, or comment is a notepad. So the credential surface an explorer gets is the existing ward-kdl-write tier (create/edit, no delete), and "read-only" means only "can't push this clone."

This supersedes the dispatch-only-token framing of ward#318. Forgejo tokens can't natively scope to "file but no push", and we don't need them to: the agent never holds a token at all.

Decision (Kai, 2026-06-25): read-only source + write-tier API creds, via a root broker. It is deliberately "weird from a security POV" - the boundary we protect is "no commit pushed from the explore clone", not "no remote writes".

The boundary: credential-enforced by absence

The full coilyco-ops bot token is never in the agent's environment:

  • Write it root-only: /etc/ward/forgejo-token, mode 0600 root:root, not readable by the agent gid.
  • Scrub FORGEJO_TOKEN from the agent's env at the setpriv drop (the way ANTHROPIC_* already is at entrypoint.sh's launch line).

A root broker is the only credentialed actor - a daemon on /run/ward-broker.sock (group-readable by the agent, same shape as the ward#319 socat bridge). It exposes exactly the explorer's capabilities and holds the token:

  • ward-kdl-write forgejo surface - the broker shells ward-kdl-write ops forgejo <verb> as root for create/edit issue/label/release/comment/etc. (no delete leaf - that's admin; no push - the binary has no git surface at all).
  • dispatch #N - the broker shells ward agent headless ... as root, which reseeds the sibling's --env-file with the full token so the sibling still does its own feature -> merge -> push in its own sealed container.

Because the agent holds no credential, bare git push https://...@host, curl to the API, and even ward ops forgejo all just fail with no creds. The only path to the remote is the broker, and the broker only does write-tier API ops + dispatch - no push, no delete. This closes every leak path enumerated under ward#315 / ward#318 (hand-built push URL, ward git push, re-created credential file, raw API curl).

Build pieces (do NOT bundle - this is the high-risk live-dispatch path)

  1. Token confinement - root-only token file + scrub FORGEJO_TOKEN at the drop. Gate on WARD_READONLY (explore) initially.
  2. Broker daemon - lifecycle in the entrypoint (start as root before the drop, group-own the socket to the agent gid), a small request protocol (file/edit/comment issue, dispatch #N), authorize each request against the write-tier surface, execute as root with the token.
  3. Client rewiring - inside a brokered explore container, ward ops forgejo <write verb> and the dispatch launcher route to the broker socket instead of resolving a local token. A WARD_BROKER_SOCK env var is the natural switch.
  4. Convergence with cli-guard - the broker is a credential-seeding + dispatch mechanism, so it overlaps ward#268 (extract credseed to cli-guard/pkg/credseed) and ward#267 (collapse ward agent dispatch onto cli-guard dispatch.Dispatcher). Decide whether the broker lands in ward or in cli-guard before building - getting that boundary right up front avoids a re-host later.

Discarded along the way

A narrow ward-kdl-dispatch tier (read + issue create/edit/comment, no repo mutation) was prototyped and discarded: real explorers need write access wider than issue-authoring, so explore uses the existing ward-kdl-write tier instead. No new tier is needed.

ward#318 (superseded home issue), ward#315 (explore dispatch landing), ward#319 (socat bridge - the socket-grant shape the broker reuses), ward#321 (docker client in image), ward#323 (host-path-independent dispatch), ward#267 + ward#268 (cli-guard dispatch + credseed extraction), cli-guard#139.

## The reframe "Read-only" for `warded explore` is about the **git source tree** (this clone can't push commits), not the Forgejo API. A real infra / deploy / ser8 explorer that can't file an issue, flip a label, cut a release, or comment is a notepad. So the credential surface an explorer gets is the existing **`ward-kdl-write`** tier (create/edit, no delete), and "read-only" means only "can't push this clone." This supersedes the dispatch-only-token framing of ward#318. Forgejo tokens can't natively scope to "file but no push", and we don't need them to: the agent never holds a token at all. Decision (Kai, 2026-06-25): **read-only source + write-tier API creds**, via a **root broker**. It is deliberately "weird from a security POV" - the boundary we protect is "no commit pushed from the explore clone", not "no remote writes". ## The boundary: credential-enforced by absence The full `coilyco-ops` bot token is **never in the agent's environment**: - Write it root-only: `/etc/ward/forgejo-token`, mode 0600 root:root, not readable by the agent gid. - Scrub `FORGEJO_TOKEN` from the agent's env at the `setpriv` drop (the way `ANTHROPIC_*` already is at entrypoint.sh's launch line). A **root broker** is the only credentialed actor - a daemon on `/run/ward-broker.sock` (group-readable by the agent, same shape as the ward#319 socat bridge). It exposes exactly the explorer's capabilities and holds the token: - **ward-kdl-write forgejo surface** - the broker shells `ward-kdl-write ops forgejo <verb>` as root for create/edit issue/label/release/comment/etc. (no `delete` leaf - that's admin; no push - the binary has no git surface at all). - **dispatch `#N`** - the broker shells `ward agent headless ...` as root, which reseeds the sibling's `--env-file` with the full token so the sibling still does its own feature -> merge -> push in its own sealed container. Because the agent holds no credential, bare `git push https://...@host`, `curl` to the API, and even `ward ops forgejo` all just **fail with no creds**. The only path to the remote is the broker, and the broker only does write-tier API ops + dispatch - no push, no delete. This closes every leak path enumerated under ward#315 / ward#318 (hand-built push URL, `ward git push`, re-created credential file, raw API curl). ## Build pieces (do NOT bundle - this is the high-risk live-dispatch path) 1. **Token confinement** - root-only token file + scrub `FORGEJO_TOKEN` at the drop. Gate on `WARD_READONLY` (explore) initially. 2. **Broker daemon** - lifecycle in the entrypoint (start as root before the drop, group-own the socket to the agent gid), a small request protocol (file/edit/comment issue, dispatch #N), authorize each request against the write-tier surface, execute as root with the token. 3. **Client rewiring** - inside a brokered explore container, `ward ops forgejo <write verb>` and the dispatch launcher route to the broker socket instead of resolving a local token. A `WARD_BROKER_SOCK` env var is the natural switch. 4. **Convergence with cli-guard** - the broker is a credential-seeding + dispatch mechanism, so it overlaps ward#268 (extract credseed to `cli-guard/pkg/credseed`) and ward#267 (collapse `ward agent` dispatch onto `cli-guard` `dispatch.Dispatcher`). Decide whether the broker lands in ward or in cli-guard before building - getting that boundary right up front avoids a re-host later. ## Discarded along the way A narrow `ward-kdl-dispatch` tier (read + issue create/edit/comment, no repo mutation) was prototyped and **discarded**: real explorers need write access wider than issue-authoring, so explore uses the existing `ward-kdl-write` tier instead. No new tier is needed. ## Related ward#318 (superseded home issue), ward#315 (explore dispatch landing), ward#319 (socat bridge - the socket-grant shape the broker reuses), ward#321 (docker client in image), ward#323 (host-path-independent dispatch), ward#267 + ward#268 (cli-guard dispatch + credseed extraction), cli-guard#139.
Owner

Worked through in a warded explore session — decision + two corrections + the split

Piece 4 decided: cli-guard core + ward glue (Kai, 2026-06-25)

The broker's policy core lands in cli-guard (importable by both, converges ward#268);
ward keeps only the entrypoint lifecycle glue that starts the daemon before the
setpriv drop. We extract just the credseed slice we need from ward#268, not the whole
of ward#267/#268. This is the path that avoids re-hosting the policy core later.

  • cli-guard/pkg/broker — request protocol (file/edit/comment issue, dispatch #N),
    the Server loop, write-tier authorization. Execution is injected (an interface)
    so the cli-guard package stays git/container-agnostic.
  • cli-guard/pkg/credseed — the token-seed slice (the ward#268 slice).
  • ward provides the daemon main that wires broker.Server with the ward-kdl-write
    executor + the root-only token, plus the entrypoint lifecycle and socket group-grant
    (reusing the ward#319 socat shape).

Dependency direction confirmed: ward imports cli-guard, never the reverse
(ward/go.mod requires cli-guard; cli-guard has no ward import), so the shared core
must live in cli-guard to be importable by both.

Correction 1: build piece 1 is the cutover, not the first step

The issue lists "scrub FORGEJO_TOKEN at the drop" as piece 1. Grounded against the code,
that flip breaks explore capability the moment it lands without the broker serving —
ward ops forgejo and dispatch both run off the inherited FORGEJO_TOKEN today
(ops.go:109 forgejoTokenResolver, container.go:267 resolveForgejoToken; the explore
revoke at revoke_push_credential deletes the git cred file but keeps the env token).
Scrub-first turns explore into the exact notepad this issue rejects.

Correct order: broker + client rewiring first, in dual mode (token still present, but
routed through the broker when WARD_BROKER_SOCK is set), then scrub the token last.
Piece 1 is logically the final cutover.

Correction 2: ward-kdl-write is not in the container image today

Only ward + ward-kdl install. The tier binaries (ward-kdl-read/write/admin) build to
bin/ via make build-ward-kdl-forgejo-tiers (Makefile:36-56) but are not shipped. The
broker can't shell a binary that isn't there, so "ship ward-kdl-write into dev-base"
is an unlisted sub-task of the ward-glue piece. (The tier binary is the right executor and
not ward ops forgejo, because the latter mounts the full admin guardfile — delete
included — whereas ward-kdl-write is the create/edit-only enforcement boundary.)

Confirmed-good in the design (grounded)

  • The confinement template already exists: ANTHROPIC_* is scrubbed right before the drop
    (entrypoint.sh:752, Go container_bootstrap.go:247). FORGEJO_TOKEN gets the same.
  • The daemon shape already exists: the ward#319 socat bridge is a root background process
    started pre-drop, socket group-owned to the agent gid at 0660, agent pointed at it by env
    (DOCKER_HOST there, WARD_BROKER_SOCK here). entrypoint.sh:114, Go :343.

Ordered build units (do NOT bundle)

  1. A — cli-guard pkg/broker + pkg/credseed (foundation, unblocked). Filed + dispatched.
  2. B — ward broker daemon main + entrypoint lifecycle + ship ward-kdl-write to dev-base
    (blocked on A landing + a cli-guard dep bump).
  3. C — ward client rewiring (ops.go:109, container.go:267 route to WARD_BROKER_SOCK,
    dual mode). Blocked on A/B.
  4. D — cutover: root-only token file + scrub FORGEJO_TOKEN at the drop, gated on
    WARD_READONLY. Last. Blocked on B/C.

B/C/D are held (not dispatched) until A lands, so a sibling isn't dispatched against a
package that doesn't exist yet. They will be split into their own issues when A lands and
the executor interface is concrete.

## Worked through in a `warded explore` session — decision + two corrections + the split ### Piece 4 decided: **cli-guard core + ward glue** (Kai, 2026-06-25) The broker's **policy core** lands in cli-guard (importable by both, converges ward#268); **ward keeps only the entrypoint lifecycle glue** that starts the daemon before the `setpriv` drop. We extract just the credseed slice we need from ward#268, not the whole of ward#267/#268. This is the path that avoids re-hosting the policy core later. - `cli-guard/pkg/broker` — request protocol (file/edit/comment issue, dispatch #N), the `Server` loop, write-tier authorization. Execution is **injected** (an interface) so the cli-guard package stays git/container-agnostic. - `cli-guard/pkg/credseed` — the token-seed slice (the ward#268 slice). - `ward` provides the daemon `main` that wires `broker.Server` with the `ward-kdl-write` executor + the root-only token, plus the entrypoint lifecycle and socket group-grant (reusing the ward#319 socat shape). Dependency direction confirmed: ward imports cli-guard, never the reverse (`ward/go.mod` requires `cli-guard`; cli-guard has no ward import), so the shared core **must** live in cli-guard to be importable by both. ### Correction 1: build piece 1 is the **cutover, not the first step** The issue lists "scrub `FORGEJO_TOKEN` at the drop" as piece 1. Grounded against the code, that flip **breaks explore capability** the moment it lands without the broker serving — `ward ops forgejo` and dispatch both run off the inherited `FORGEJO_TOKEN` today (`ops.go:109` `forgejoTokenResolver`, `container.go:267` `resolveForgejoToken`; the explore revoke at `revoke_push_credential` deletes the git cred file but **keeps the env token**). Scrub-first turns explore into the exact notepad this issue rejects. Correct order: **broker + client rewiring first, in dual mode** (token still present, but routed through the broker when `WARD_BROKER_SOCK` is set), **then** scrub the token last. Piece 1 is logically the final cutover. ### Correction 2: `ward-kdl-write` is **not in the container image** today Only `ward` + `ward-kdl` install. The tier binaries (`ward-kdl-read/write/admin`) build to `bin/` via `make build-ward-kdl-forgejo-tiers` (`Makefile:36-56`) but are not shipped. The broker can't shell a binary that isn't there, so **"ship `ward-kdl-write` into dev-base"** is an unlisted sub-task of the ward-glue piece. (The tier binary is the right executor and not `ward ops forgejo`, because the latter mounts the full admin guardfile — delete included — whereas `ward-kdl-write` is the create/edit-only enforcement boundary.) ### Confirmed-good in the design (grounded) - The confinement template already exists: `ANTHROPIC_*` is scrubbed right before the drop (`entrypoint.sh:752`, Go `container_bootstrap.go:247`). `FORGEJO_TOKEN` gets the same. - The daemon shape already exists: the ward#319 socat bridge is a root background process started pre-drop, socket group-owned to the agent gid at 0660, agent pointed at it by env (`DOCKER_HOST` there, `WARD_BROKER_SOCK` here). `entrypoint.sh:114`, Go `:343`. ### Ordered build units (do NOT bundle) 1. **A — cli-guard `pkg/broker` + `pkg/credseed`** (foundation, unblocked). Filed + dispatched. 2. **B — ward broker daemon `main` + entrypoint lifecycle + ship `ward-kdl-write` to dev-base** (blocked on A landing + a cli-guard dep bump). 3. **C — ward client rewiring** (`ops.go:109`, `container.go:267` route to `WARD_BROKER_SOCK`, dual mode). Blocked on A/B. 4. **D — cutover**: root-only token file + scrub `FORGEJO_TOKEN` at the drop, gated on `WARD_READONLY`. Last. Blocked on B/C. B/C/D are held (not dispatched) until A lands, so a sibling isn't dispatched against a package that doesn't exist yet. They will be split into their own issues when A lands and the executor interface is concrete.
Author
Member

Confirmed headless during the mode sweep: the design decision is already on record in the body (2026-06-25, root broker / read-only source / write-tier creds). Dispatchable as specced. Recorded by Claude Code (Fable) during the 2026-07-01 ward launch triage session with Kai.

Confirmed headless during the mode sweep: the design decision is already on record in the body (2026-06-25, root broker / read-only source / write-tier creds). Dispatchable as specced. Recorded by Claude Code (Fable) during the 2026-07-01 ward launch triage session with Kai.
Author
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-329 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-04T17:26:23Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-329` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-04T17:26:23Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Owner

WARD-OUTCOME: done - #329's Unit B broker was already landed+green; closed the tracker, filed Unit D (token scrub) as ward#608 with its cross-repo blocker.

Honest retro: this one fought back by being mostly already done. I came in expecting to build the root broker, and front-loading the clone turned up commit 7a53fe0 ("ward#329 unit B") and 2dac155 ("ward#334 unit C") sitting in main - the daemon, socket lifecycle, executor, entrypoint, and ward-kdl-write ship all present, tests green. The landing commit just never carried a closes #329, so the tracker drifted open.

The real work became figuring out what was actually left and whether it was safe to do here. It wasn't. The one remaining piece - Unit D, scrubbing the raw FORGEJO_TOKEN from the dropped agent's env - looks like a one-line unset, but reads go direct and resolve auth from that same token, ward-kdl-read auths from SSM (no AWS in an explore box), and cli-guard's broker has no read op. So a naive scrub blinds every explore session, and the safe fix starts in cli-guard, which is read-only substrate from this container. That's a genuine wall, not something to bulldoze on a live-dispatch path the issue itself flagged as high-risk.

So I didn't fake a cutover to claim a bigger diff. I verified the landed boundary, wrote the blocker analysis into docs/broker.md next to the existing "Dual mode" note, filed ward#608 to track Unit D with the cli-guard-first ordering, and closed #329 since its named deliverable is done.

Confidence: high on the diagnosis and the doc/tracking; the code claim rests on the prior commits + a passing broker test run, not on my own broker code. Rough edge / judgement call worth a glance: I closed #329 rather than leaving it open as a Unit-D epic, reading docs/broker.md's own mapping (#329 = Unit B, done) as intent. If you'd rather #329 stay the boundary epic until D lands, reopen it and close #608 into it - both reversible. Unit D itself is the real follow-up, and it's a two-repo job.

WARD-OUTCOME: done - #329's Unit B broker was already landed+green; closed the tracker, filed Unit D (token scrub) as ward#608 with its cross-repo blocker. Honest retro: this one fought back by being mostly already done. I came in expecting to build the root broker, and front-loading the clone turned up commit 7a53fe0 ("ward#329 unit B") and 2dac155 ("ward#334 unit C") sitting in main - the daemon, socket lifecycle, executor, entrypoint, and ward-kdl-write ship all present, tests green. The landing commit just never carried a `closes #329`, so the tracker drifted open. The real work became figuring out what was actually *left* and whether it was safe to do here. It wasn't. The one remaining piece - Unit D, scrubbing the raw FORGEJO_TOKEN from the dropped agent's env - looks like a one-line `unset`, but reads go direct and resolve auth from that same token, ward-kdl-read auths from SSM (no AWS in an explore box), and cli-guard's broker has no read op. So a naive scrub blinds every explore session, and the safe fix starts in cli-guard, which is read-only substrate from this container. That's a genuine wall, not something to bulldoze on a live-dispatch path the issue itself flagged as high-risk. So I didn't fake a cutover to claim a bigger diff. I verified the landed boundary, wrote the blocker analysis into docs/broker.md next to the existing "Dual mode" note, filed ward#608 to track Unit D with the cli-guard-first ordering, and closed #329 since its named deliverable is done. Confidence: high on the diagnosis and the doc/tracking; the code claim rests on the prior commits + a passing broker test run, not on my own broker code. Rough edge / judgement call worth a glance: I closed #329 rather than leaving it open as a Unit-D epic, reading docs/broker.md's own mapping (#329 = Unit B, done) as intent. If you'd rather #329 stay the boundary epic until D lands, reopen it and close #608 into it - both reversible. Unit D itself is the real follow-up, and it's a two-repo job.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/ward#329
No description provided.