Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Gated Effects

An effect is an action on the world that an agent may propose but must not perform: pushing to a branch someone else consumes, publishing, deploying. services.cowboy.pubsub.effects gives each declared effect a fixed shape — the agent names a commit, a human sees exactly that commit on an approval card, and after ✅ the host applies exactly that commit as a user of the operator’s choosing. Nothing the agent says on the card is trusted; nothing the agent can do between the card and the push changes what gets pushed.

It reuses the Outbox Approval Protocol unchanged. What is new is the seam on the far side of the gate: a bridge with no privilege of its own starts a static systemd template unit through polkit, and that unit — running as the declared runAs user inside the standard sandbox — does the work.

The flow

agent                      effects bridge                     host
-----                      --------------                     ----
commit in workspace
effects-request tool ───▶  effects:outbox entry
  (git bundle into        pre_send:
   the handoff dir)         systemctl start --wait
                              cowboy-effect-<name>@describe-<sha>  ──▶ unit (User=runAs):
                                                                      unbundle, fetch, policy,
                                                                      write results/describe-<sha>.json
                            read the result; refuse ⇒ ack + message
                            ok ⇒ pin sha/base/description
                          approval card → notify channel
                                       ⏳ … human reacts ✅
                          send():
                            guard pinned sha == requested sha
                            mark_effect
                            systemctl start --wait
                              cowboy-effect-<name>@apply-<sha>  ─────▶ unit: re-fetch, re-run policy,
                                                                      push --force-with-lease,
                                                                      verify, write apply-<sha>.json
                            result → agent inbox [effects <id>] …
                                   → notify channel (untagged copy)

Two runs of the same program, addressed by the same 40-hex sha: describe before the card, apply after it. The sha in the unit instance name is the whole of what the bridge can choose; polkit permits only that one action, on only those unit names, for only the bridge user.

Declaring an effect

services.cowboy.pubsub.effects = {
  enable = true;
  approval.notify_channel = "<discord channel id>";   # required; there is no unapproved mode
  declared.blog = {
    runAs = "<user>";                                  # the unit's User=; never root, never the bridge
    gitPush = {
      remoteUrl = "git@github.com:me/blog.git";        # ssh://, git@, or file:/// — never https
      branch = "main";
      webUrl = "https://github.com/me/blog";           # optional; result messages link the commit
      allowedPaths = [ "docs/posts/" ];                # directories (or exact files) every commit must stay inside
      maxCommits = 3;
      knownHosts = "github.com ssh-ed25519 AAAA…";     # required for ssh remotes
    };
    credentials.sshkey = "/home/<user>/.ssh/keys/github"; # loaded with LoadCredential=, readable by root only
  };
};

approval.required is read-only true. An effects bridge that starts without approval configured exits at authenticate(), and the module refuses to evaluate without a notify channel or with the notify bridge disabled — the failure mode where a “gated” effect quietly evaluates to an ungated one is the one this module exists to close.

Each declared effect produces:

  • a group cowboy-effect-<name> whose members are the agents plus runAs (never the bridge), and a setgid handoff directory /var/lib/cowboy-handoff/<name> (2770) where the agent’s bundles land;
  • a state directory /var/lib/cowboy-effect-<name> owned by runAs holding the mirror repo.git (0700), results/, and the lock;
  • a template unit cowboy-effect-<name>@.service with User=runAs, NoNewPrivileges, ProtectSystem=strict, ProtectHome, an empty capability set, IPAddressDeny outside the remote, and the docker socket inaccessible;
  • one polkit rule allowing cowboy-bridge-effects to start (and only start) cowboy-effect-<name>@(describe|apply)-<40 hex>.service;
  • an effects-request tool visible to the effect’s agents.

Set runner instead of gitPush to supply your own program. It receives the instance name (describe-<sha> or apply-<sha>) as $1, runs as runAs with STATE_DIRECTORY set, and must write $STATE_DIRECTORY/results/<verb>-<sha>.json with at least {"ok": bool, "message": str}; a describe result should also carry what the card shows (base, count, commits, files, diffstat, policy).

What the agent does

The effects-request tool takes effect, sha, repo, and content. It verifies the sha is a commit in repo and that refs/remotes/origin/<branch> exists there, writes a git bundle of sha ^origin/<branch> into the handoff directory (size-capped), and files one entry on effects:outbox. It returns {"status": "pending_approval", "request_id": …} and nothing has run.

The outcome arrives later in the agent’s inbox as a message starting [effects <request_id>]. A refusal — the branch moved, a file outside allowedPaths, a merge commit, too many commits, a symlink — is text, not a retry: the agent rebases and files a new sha, which is a new approval. The policy is checked on every commit in the range, not on the net diff, so an add-then-revert pair cannot smuggle a path into history that the card never showed. A newer request for the same effect from the same agent supersedes an older one still waiting; a refused request supersedes nothing.

What the human sees

Approval needed [effects/blog]: push to git@github.com:me/blog.git main (as <user>)
Requested by agent: `publish monix — reviewed, no blockers`
Commit 3f9c2a1e…  (fast-forward from 096ab9d, 1 commit, policy ok)
  3f9c2a1 blog: publish monix (agent-gated) (agent, 2026-08-25)
Files:
  A  docs/posts/2026-08-25-monix.md
 1 file changed, 118 insertions(+)
Inspect: git -C /var/lib/cowboy-effect-blog/repo.git show 3f9c2a1
React ✅ to push exactly this commit, ❌ to reject. Expires in 60 min; if the branch moves first the push refuses itself.

Every line but the quoted Requested by one comes from the describe run, which read the bundle as runAs, not from the agent. The agent’s own text is a single quoted line, capped, with backticks neutralised, so it cannot imitate the trusted lines. The Inspect: path is a real mirror on the host for a second look before reacting.

What can still go wrong

  • The branch moves. apply refetches and requires the branch to be at the base describe recorded; the push itself is --force-with-lease against that base. Either check failing is a refusal with the new head named.
  • The unit is interrupted. The bridge marks the entry phase=effect before starting apply, so a restart mid-push reports uncertainty instead of re-running. on_uncertain reads results/applied-<sha>.json and says whether the push in fact completed.
  • A stale result. The bridge treats a result file older than the unit it just started as absent — a describe that failed to run (polkit said no, say) cannot be answered by the previous run’s file.
  • runAs is a login user. The unit inherits that user’s DB groups (the module warns). The sandbox is what bounds them; a dedicated uid with a deploy key is the better end state, and is a one-line change to runAs and credentials.sshkey.

See Approvals & Outbox for the protocol underneath and specs/THREAT-MODEL.md §8 for the residual authorities this adds.

Rebuilds and generated environments

The rebuild bridge is separate from a declared Git effect. Enable it through services.cowboy.pubsub.rebuild and configure its repository and approval settings. The rebuild-request tool queues a system or home rebuild and returns a request ID. A queued or pending-approval result is not a completed rebuild. The later outcome arrives in the agent inbox tagged [rebuild <request_id>].

A system request builds the configured remote revision with nixos-rebuild boot, then starts activation in a separate systemd unit. A home request builds and activates the requesting agent’s home-manager profile. A home rebuild cannot apply system service or networking changes. Local unpushed edits are not the remote revision the bridge builds.

For system requests, a success message means the build finished and activation was launched; it does not prove the asynchronous switch completed. Inspect the activation unit and running service before calling the change live:

sudo systemctl status cowboy-activate.service
sudo journalctl -u cowboy-activate.service
cowboy doctor

The bridge compares the built system with the previously running generation, or the built home profile with the previous home generation. When it can prove they are equal, its result says the rebuild deployed no change. Check the request type, pushed revision and selected flake inputs before retrying. An unreadable generation is unknown, not evidence of a no-op. After an interrupted activation, inspect the running system and the bridge’s status record at /var/lib/cowboy-rebuild/last-rebuild.json before submitting again.

Ordinary Docker also has implemented generation machinery: settings can scaffold a flake in the persistent configuration volume, bake its container output and launch the resulting supervisor. The host records generation metadata for cowboy list; that record is not a liveness check. Configuration and workspace volumes are mutable state to preserve across replacement. See Deployment Paths for launch setup.

These mechanisms generate and apply environments. They do not implement an autonomous rollback or pull-request review workflow.