Skip to content

devagent OS-user boundary — tier-1 host provisioning + rollback (pop-mini pilot) #78

Description

@thompsonson

Summary

The tier-1 half of the devagent OS-user boundary: the privileged, once-only,
host-specific steps a human runs and can roll back. Current pilot host: computer
(was pop-mini; flipped by the operator). The mechanism is host-agnostic — any
modern systemd host where gpg-agent is socket-activated. Repeat per host.

This issue does not restate the design — the merged doc set is the authority:

  • Model, vocabulary, and the V1–V7 decisions: dev docs/design/devagent-boundary.md
    • docs/design/devagent-security-boundary.md (merged, dev#186).
  • Tier-2 — idempotent mechanicals (socketdir loan link + its user unit, the
    agents.slice user unit, /srv/dev-shared checkout + ACLs, broker-contract
    verification): owned by dev provision devagent --apply. dev provision devagent --dry-run is the single source of truth for those commands and emits the
    tier-1 commands below — treat any divergence between it and this issue as a bug
    in one of them.
  • Tier-3 — continuous health: dev doctor --devagent.
  • App-code contract ([project.x] user =, uid-aware tmux dispatch, the broker
    op-set): dev#154.

Prerequisites

  • A dedicated sign-only ([S]-only) OpenPGP signing subkey on mt's key,
    appointed as the loaned keygrip. A restricted extra-socket permits PKSIGN and
    PKDECRYPT — pinning the loan to an [S]-only subkey is what excludes decryption
    (V6). Default keys have no such subkey; create one (gpg --edit-key <fpr>
    addkey → sign-only) or via dev#162 dev key create. Record its fingerprint and
    keygrip: gpg --list-keys --with-subkey-fingerprints --with-keygrip.
  • mt's gpg-agent kept unlocked by the existing weekly preset flow
    (allow-preset-passphrase + gpg-preset-passphrase <sign-subkey-keygrip>).
  • Both mt and devagent get enable-linger. /run/gnupg/shared is tmpfs
    and the extra-socket only exists while mt's gpg-agent is running; without mt
    lingering, a reboot breaks devagent signing until mt next logs in.

Tier-1 steps

1. devagent user + shared group

getent group devgroup   || sudo groupadd devgroup
id -u devagent >/dev/null 2>&1 || \
  sudo useradd -m -s /usr/sbin/nologin -G devgroup devagent   # no interactive shell — v1 is loan-only, the broker builds env from scratch
sudo usermod -aG devgroup mt
sudo loginctl enable-linger devagent
sudo loginctl enable-linger mt          # keeps the lender (mt's gpg-agent) alive across reboots

Verify: id devagent shows devgroup; loginctl show-user devagent and
loginctl show-user mt both show Linger=yes.

2. Relocate + group-grant the restricted extra-socket

The pilot host runs gpg-agent --supervised (systemd socket-activated)
verified on both computer (gpg 2.4.8) and pop-mini (gpg 2.2.27). Two things
follow:

  • extra-socket in gpg-agent.conf is ignored in supervised mode — gpg-agent
    gets pre-bound fds from systemd and never creates its own sockets. Do not add it.
  • /run/user/<uid> is 0700 mt:mt, logind-owned, mode hardcoded — a socket
    anywhere under it is connect()-unreachable cross-uid regardless of its own mode.
    The extra socket must move out of /run/user/<uid>, and on a socket-activated
    host that is done in the .socket unit, not gpg-agent.conf.

Keep in ~mt/.gnupg/gpg-agent.conf only:

allow-preset-passphrase
max-cache-ttl 604800

Stated decision, not a side effect: max-cache-ttl 604800 is per-agent, not
per-key — it relaxes cache lifetime for all of mt's keys for a week. Accepted on
a single-operator host; recorded here so the choice is deliberate.

Shared directory (tmpfs — recreated each boot), setgid so anything created in
it lands devgroup/etc/tmpfiles.d/gnupg-shared.conf:

d /run/gnupg/shared 2750 mt devgroup -

User drop-in on gpg-agent-extra.socket — relocates the socket and sets mode
natively. SocketGroup=devgroup is belt-and-braces: the socket already inherits
devgroup from the setgid 2750 dir on creation, so SocketGroup= may log a
benign "Failed to set socket group" if mt's systemd --user session predates the
usermod -aG devgroup mt above (a non-root manager can't chgrp to a group it
isn't in) — the setgid dir covers it either way. The empty ListenStream= resets
the vendor value before the new one:

# ~mt/.config/systemd/user/gpg-agent-extra.socket.d/shared.conf
[Socket]
ListenStream=
ListenStream=/run/gnupg/shared/S.gpg-agent.extra
SocketMode=0660
SocketGroup=devgroup
sudo systemd-tmpfiles --create /etc/tmpfiles.d/gnupg-shared.conf
systemctl --user daemon-reload
gpgconf --kill gpg-agent                        # KILL FIRST — see note
systemctl --user restart gpg-agent-extra.socket
gpg -K >/dev/null 2>&1 && echo "mt gpg ok"      # sanity: agent re-activates on this call

Order matters. systemd refuses restart gpg-agent-extra.socket while its
Accept=no acceptor gpg-agent.service is active ("Socket service … already
active, refusing") — so a restart-then-kill order fails at the restart. Kill the
agent first; the .socket restart then binds the relocated /run/gnupg/shared
path, and gpg-agent re-activates on the next connection. (Killing also avoids the
separate stale-fd trap: a running agent keeps the old, now-unlinked socket fd and
never serves the relocated path — a "V2 blocked" false negative.)

If usermod -aG devgroup mt was run in this same session, log out/in (or
sudo loginctl terminate-user mt) before this step so SocketGroup= applies
cleanly — otherwise rely on the setgid dir and ignore the journal warning.

Autonomous (--daemon) hosts — non-systemd, or gpg-agent not socket-activated:
there, use extra-socket /run/gnupg/shared/S.gpg-agent.extra in gpg-agent.conf
plus an ExecStartPost=/bin/sh -c 'chgrp devgroup … && chmod 0660 …' on the
agent service. Any modern Ubuntu/Pop host is socket-activated — use the drop-in
above.

3. V2 go/no-go pre-test — before the sudoers line

Prove a different uid can actually connect() to the extra-socket. Nothing
downstream is worth wiring until this passes.

DEVAGENT_UID=$(id -u devagent)
# sudo resets the env; gpgconf needs XDG_RUNTIME_DIR to place the 0700 socketdir
sudo -u devagent env XDG_RUNTIME_DIR="/run/user/$DEVAGENT_UID" gpgconf --create-socketdir
sudo -u devagent ln -sfn /run/gnupg/shared/S.gpg-agent.extra \
     "/run/user/$DEVAGENT_UID/gnupg/S.gpg-agent"
gpg --export <PRIMARY_FPR> | sudo -u devagent gpg --homedir /home/devagent/.gnupg --import
echo test | sudo -u devagent env XDG_RUNTIME_DIR="/run/user/$DEVAGENT_UID" \
     gpg --homedir /home/devagent/.gnupg -u '<SIGN_SUBKEY_FPR>!' --sign > /dev/null && echo "V2 OK"

If connect() is refused: confirm ls -l /run/gnupg/shared/S.gpg-agent.extra
shows srw-rw---- mt devgroup and the dir is 2750 mt devgroup — a wrong group or
a socket still under /run/user/<uid> is the usual cause. If the drop-in took and
it still fails, socket forwarding (socat / ssh -R) is the last resort; if that
also fails the design flips to SSH signing or a per-uid key (dev#162) — stop and
escalate, do not proceed to step 5.

The socketdir symlink created here is on tmpfs and vanishes on reboot — its
permanent home is a devagent user unit created in tier-2 by dev provision.
This step only proves the mechanism.

4. Tighten mt's home

chmod 700 /home/mt

Reversible (chmod 755), so safe to apply and observe — but check first:

  • sysbak / rsnapshot: runs as root (ignores 700) or drops privileges? If it
    drops privs it loses read access to ~mt.
  • Nothing reads ~mt/.local/share/chezmoi from another identity — the pilot
    clones dotfiles fresh, it does not read mt's working copy.
  • Editor / LSP remote-helpers that stat ~mt.
  • systemd system units with ProtectHome= interactions.
  • Confirm nothing devagent needs lives under ~mt (the extra-socket is at
    /run/gnupg/shared, not ~mt).

5. Sudoers — the trust root

sudo visudo -cf /dev/stdin <<'EOF'
mt ALL=(devagent) NOPASSWD: /usr/lib/dev/agent-launch
EOF
# then install as /etc/sudoers.d/dev-agent-launch, mode 0440

Absolute path, no ARGV constraint — by design. Security rests on the broker
(agent-launch) enumerating its ops and self-validating its own argv, not on
sudoers argument matching (dev#154). Install only after step 3 passed and the
broker binary is in place (tier-2 verifies its op-set against the dev#154
contract).

Verification checklist (tier-1)

  • id devagentdevgroup; loginctl show-user devagent and ... mtLinger=yes
  • ~mt/.gnupg/gpg-agent.conf has allow-preset-passphrase + max-cache-ttl 604800 and no extra-socket line
  • /run/gnupg/shared is a dir, mode 2750, owner mt:devgroup
  • ls -l /run/gnupg/shared/S.gpg-agent.extrasrw-rw---- mt devgroup, i.e. a socket at that path (not under /run/user/), mode 0660, group devgroup, after systemctl --user restart gpg-agent-extra.socket
  • V2 pre-test passessudo -u devagent … --sign succeeds while mt's agent has the sign-only subkey preset
  • sudo -u devagent gpg --homedir /home/devagent/.gnupg --list-secret-keys → empty
  • loaned keygrip is a sign-only subkey — gpg --list-keys --with-colons --with-keygrip: the sub: whose following grp: matches shows capability s only (not se / e)
  • ls -ld /home/mtdrwx------
  • sudo visudo -cf /etc/sudoers.d/dev-agent-launch → OK; sudo -n -u devagent /usr/lib/dev/agent-launch runs with no password prompt

Rollback

sudo userdel -r devagent
sudo gpasswd -d mt devgroup && sudo groupdel devgroup
sudo rm -f /etc/sudoers.d/dev-agent-launch /etc/tmpfiles.d/gnupg-shared.conf
rm -rf ~mt/.config/systemd/user/gpg-agent-extra.socket.d
systemctl --user daemon-reload && systemctl --user restart gpg-agent-extra.socket   # restores the vendor path /run/user/<uid>/gnupg/S.gpg-agent.extra
sudo rm -rf /run/gnupg/shared
# edit ~mt/.gnupg/gpg-agent.conf — remove allow-preset-passphrase / max-cache-ttl, then:
gpg-connect-agent reloadagent /bye
chmod 755 /home/mt          # or the pre-change mode
sudo loginctl disable-linger devagent
# tier-2 artefacts (agents.slice + socketdir user units, /srv/dev-shared ACLs): dev provision devagent revert path (dev#154)

The dedicated sign-only subkey can stay on mt's key — it is harmless unused. Remove
with gpg --edit-key <fpr>key Ndelkey if desired.

Non-goals

  • No key generation here — the sign-only subkey is a prerequisite (dev#162
    dev key create).
  • One shared devagent; no per-project users in v1.
  • No cross-host automation — one pilot host at a time (currently computer),
    repeat per host once proven.
  • No run_once_* chezmoi script for these sensitive steps on the first pass —
    done by hand and reviewed; scripted only once proven safe.

Companions

  • dev docs/design/devagent-boundary.md + devagent-security-boundary.md
    model, vocabulary, V1–V7 (merged in dev#186).
  • dev#154 — app-code scope: user = field, uid-aware tmux dispatch, broker op-set
    contract, attach-via-proxy.
  • dev#162 — dev key create (the sign-only subkey).
  • dev#159 — per-role git signing identity (GIT_CONFIG_GLOBAL); devagent's git
    config is deferred to it.
  • dev#129 — structured agent API; shrinks the attach/scrape surface once it lands.
  • dev#151 — direct-launch process leak (reap).

https://claude.ai/code/session_01WDcE2CAYzzJTMY6ubRmt94

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions