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 plusrunAs(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 byrunAsholding the mirrorrepo.git(0700),results/, and the lock; - a template unit
cowboy-effect-<name>@.servicewithUser=runAs,NoNewPrivileges,ProtectSystem=strict,ProtectHome, an empty capability set,IPAddressDenyoutside the remote, and the docker socket inaccessible; - one polkit rule allowing
cowboy-bridge-effectstostart(and only start)cowboy-effect-<name>@(describe|apply)-<40 hex>.service; - an
effects-requesttool 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.
applyrefetches and requires the branch to be at the basedescriberecorded; the push itself is--force-with-leaseagainst that base. Either check failing is a refusal with the new head named. - The unit is interrupted. The bridge marks the entry
phase=effectbefore startingapply, so a restart mid-push reports uncertainty instead of re-running.on_uncertainreadsresults/applied-<sha>.jsonand 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.
runAsis 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 torunAsandcredentials.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.